Преглед на файлове

Merge branch 'feature/enterprice-wechat'

wuwenyi преди 6 дни
родител
ревизия
62634894c5
променени са 100 файла, в които са добавени 4334 реда и са изтрити 296 реда
  1. 6 0
      .dockerignore
  2. 8 2
      Dockerfile
  3. 37 60
      README.md
  4. 1 0
      docker-compose.yml
  5. 30 6
      pom.xml
  6. 13 0
      settings.xml
  7. 9 0
      src/main/java/com/qqflow/engine/common/PageRequest.java
  8. 31 0
      src/main/java/com/qqflow/engine/common/assemble/CommonAssembler.java
  9. 47 0
      src/main/java/com/qqflow/engine/common/config/LoggingInterceptor.java
  10. 21 0
      src/main/java/com/qqflow/engine/common/config/RestTemplateConfig.java
  11. 4 3
      src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java
  12. 93 0
      src/main/java/com/qqflow/engine/common/controller/FileDownloadController.java
  13. 0 44
      src/main/java/com/qqflow/engine/common/controller/FileUploadController.java
  14. 14 0
      src/main/java/com/qqflow/engine/common/enums/StringIEnum.java
  15. 31 0
      src/main/java/com/qqflow/engine/common/po/BaseModel.java
  16. 20 0
      src/main/java/com/qqflow/engine/common/po/BaseUpdateModel.java
  17. 163 125
      src/main/java/com/qqflow/engine/common/service/ExcelParseService.java
  18. 176 0
      src/main/java/com/qqflow/engine/common/service/RedissonService.java
  19. 70 0
      src/main/java/com/qqflow/engine/common/util/Assert.java
  20. 546 0
      src/main/java/com/qqflow/engine/common/util/JsonUtil.java
  21. 10 3
      src/main/java/com/qqflow/engine/common/util/JwtUtils.java
  22. 24 0
      src/main/java/com/qqflow/engine/common/util/XmlUtil.java
  23. 124 0
      src/main/java/com/qqflow/engine/common/util/id/AesUtil.java
  24. 36 0
      src/main/java/com/qqflow/engine/common/util/id/CodeUtil.java
  25. 140 0
      src/main/java/com/qqflow/engine/common/util/id/GenIntegerUtil.java
  26. 109 0
      src/main/java/com/qqflow/engine/common/util/id/GenLong15ForTokenUtil.java
  27. 116 0
      src/main/java/com/qqflow/engine/common/util/id/GenLong15Util.java
  28. 116 0
      src/main/java/com/qqflow/engine/common/util/id/GenLong19Util.java
  29. 48 0
      src/main/java/com/qqflow/engine/common/util/id/IdUtil.java
  30. 23 0
      src/main/java/com/qqflow/engine/common/util/id/TokenUtil.java
  31. 15 0
      src/main/java/com/qqflow/engine/common/util/id/TraceidUtil.java
  32. 5 28
      src/main/java/com/qqflow/engine/config/EmbeddedRedisConfig.java
  33. 51 0
      src/main/java/com/qqflow/engine/config/EmbeddedRedisInitializer.java
  34. 31 0
      src/main/java/com/qqflow/engine/config/OpenApiConfig.java
  35. 15 21
      src/main/java/com/qqflow/engine/config/WebConfig.java
  36. 12 3
      src/main/java/com/qqflow/engine/config/security/LoginUser.java
  37. 2 1
      src/main/java/com/qqflow/engine/config/security/SecurityConfig.java
  38. 253 0
      src/main/java/com/qqflow/engine/domain/ewechat/api/EnterpriseWechatApi.java
  39. 38 0
      src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatAssembler.java
  40. 71 0
      src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatSessionAssembler.java
  41. 111 0
      src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatEventHandler.java
  42. 69 0
      src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatValidator.java
  43. 18 0
      src/main/java/com/qqflow/engine/domain/ewechat/config/EnterpriseWechatConfig.java
  44. 33 0
      src/main/java/com/qqflow/engine/domain/ewechat/config/EnterpriseWechatProperties.java
  45. 96 0
      src/main/java/com/qqflow/engine/domain/ewechat/controller/EnterpriseTestController.java
  46. 58 0
      src/main/java/com/qqflow/engine/domain/ewechat/controller/EnterpriseWechatCallbackController.java
  47. 36 0
      src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomContactController.java
  48. 16 0
      src/main/java/com/qqflow/engine/domain/ewechat/dto/EnterpriseCorpDto.java
  49. 22 0
      src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatInfoType.java
  50. 19 0
      src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatMsgEventType.java
  51. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatMsgType.java
  52. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.java
  53. 27 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.xml
  54. 9 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatDepartmentMapper.java
  55. 9 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatMemberMapper.java
  56. 14 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMapper.java
  57. 19 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMapper.xml
  58. 28 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMsgMapper.java
  59. 24 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMsgMapper.xml
  60. 20 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSuiteMapper.xml
  61. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/EnterpriseWechatEvent.java
  62. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatAuthNotice.java
  63. 25 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatCallbackReq.java
  64. 28 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatCallbackXmlReq.java
  65. 24 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatDecryptBody.java
  66. 35 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatMsgDecryptBody.java
  67. 15 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatSuiteTicket.java
  68. 27 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatAddCsReq.java
  69. 33 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatChangeSessionReq.java
  70. 28 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatCsUrlReq.java
  71. 12 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatListIdReq.java
  72. 28 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendMsgReq.java
  73. 39 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendOnEventMsgReq.java
  74. 79 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendTextMsgReq.java
  75. 23 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSessionReq.java
  76. 32 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSuiteAccessTokenReq.java
  77. 37 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSyncMsgReq.java
  78. 17 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatUseridByMobileReq.java
  79. 41 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/msg/EnterpriseWechatSendImgMsgReq.java
  80. 40 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/msg/EnterpriseWechatSendTextMsgReq.java
  81. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatAccessTokenRsp.java
  82. 19 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatAddCsRsp.java
  83. 16 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatBaseRsp.java
  84. 21 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatChangeSessionRsp.java
  85. 18 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatCsUrlRsp.java
  86. 31 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatDepartmentListRsp.java
  87. 26 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatListIdRsp.java
  88. 52 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatPermanentCodeRsp.java
  89. 14 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSendMsgRsp.java
  90. 22 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSendTextMsgRsp.java
  91. 28 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSessionRsp.java
  92. 22 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSuiteAccessTokenRsp.java
  93. 31 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSyncMsgRsp.java
  94. 24 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUploadRsp.java
  95. 27 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUserListRsp.java
  96. 14 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUseridByMobileRsp.java
  97. 18 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatEventMsg.java
  98. 25 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatFileMsg.java
  99. 25 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatImageMsg.java
  100. 46 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatMsg.java

+ 6 - 0
.dockerignore

@@ -4,3 +4,9 @@ target
 *.iml
 data
 logs
+*.md
+.DS_Store
+.gitignore
+docker-compose*.yml
+Dockerfile
+.dockerignore

+ 8 - 2
Dockerfile

@@ -1,9 +1,15 @@
+# syntax=docker/dockerfile:1
 FROM maven:3.9-eclipse-temurin-17 AS build
 WORKDIR /app
+
+# settings.xml 加速 Maven 下载,放在 cache mount 外部避免被清掉
+COPY settings.xml /root/.m2/settings.xml
 COPY pom.xml .
-RUN mvn dependency:go-offline -B
+# --mount=type=cache 将 .m2/repository 持久化,后续构建复用已下载的依赖
+RUN --mount=type=cache,target=/root/.m2/repository mvn dependency:go-offline -B -q
+
 COPY src ./src
-RUN mvn package -DskipTests -B
+RUN --mount=type=cache,target=/root/.m2/repository mvn package -DskipTests -B -q
 
 FROM eclipse-temurin:17-jre
 WORKDIR /app

+ 37 - 60
README.md

@@ -5,74 +5,55 @@
 ## 技术栈
 
 - Java 17
-- Spring Boot 3.2.5
-- Spring Security 6 + JWT
+- Spring Boot 3.2.5 + Spring Security 6 + JWT
 - MyBatis-Plus 3.5.7
-- H2 Database (开发模式文件数据库)
-- Redis (embedded-redis 开发兜底)
-
-## 环境要求
-
-- JDK 17+
-- Maven 3.8+(或项目内嵌的 apache-maven-3.9.6)
-- 可选:本地 Redis(如未安装,启动时会自动启动 embedded Redis)
+- MySQL 5.7(生产/测试) / H2(本地开发)
+- Redis + Redisson(分布式锁)
+- EasyExcel 3.3(导入导出)
+- SpringDoc OpenAPI(Swagger)
 
 ## 快速启动
 
 ```bash
 cd qqflowengine-backend
-
-# 方式一:使用系统 Maven
 mvn spring-boot:run
-
-# 方式二:使用项目内嵌 Maven
-./apache-maven-3.9.6/bin/mvn spring-boot:run
 ```
 
-服务启动后访问:
 - API 服务:`http://localhost:8080`
-- Swagger 文档:`http://localhost:8080/swagger-ui.html`
-
-## 开发说明
+- Swagger 文档:`http://localhost:8080/swagger-ui/index.html`
 
-### 数据库
+## Profile 说明
 
-开发环境使用 **H2 文件数据库**,数据文件保存在项目根目录 `data/devdb.mv.db`:
+| Profile | 数据库 | 用途 |
+|---------|--------|------|
+| `dev`(默认) | H2 文件数据库 | 本地开发,每次启动重建表和数据 |
+| `test` | MySQL | 测试环境,连接 `192.168.1.139` |
 
-```yaml
-spring.datasource.url: jdbc:h2:file:./data/devdb;MODE=MySQL;AUTO_SERVER=TRUE
-```
+切换 Profile:修改 `application.yml` 中的 `spring.profiles.active`。
 
-- `spring.sql.init.mode: always` — 每次启动都会重新执行 `schema-dev.sql` 和 `data-dev.sql`
-- 如需保留手动修改的数据,可将 `mode` 改为 `never`
-- H2 Console 在开发模式已开启,可通过 `http://localhost:8080/h2-console` 访问(生产请关闭)
+## 默认账号
 
-### 默认账号
+密码均为 `123456a`:
 
-启动后会自动初始化以下账号(密码均为 `admin123`):
+| 账号 | 类型 | 说明 |
+|------|------|------|
+| admin | super_admin | 超级管理员 |
+| zhangsan | common_user | 普通用户 |
+| lisi | dept_manager | 部门管理员 |
+| wangwu | flow_manager | 流程管理员 |
 
-| 账号 | 角色 |
-|------|------|
-| admin | 超级管理员 |
-| zhangsan | 普通用户(技术部) |
-| lisi | 部门经理(财务部) |
-| wangwu | 流程管理员(人事部) |
+角色账号(审批使用,不登录):`role_super_admin`、`role_flow_admin`、`role_normal_user`、`role_dept_manager`
 
-### Redis
+## 账户体系
 
-- 优先连接本地 `localhost:6379`
-- 如果本地未安装 Redis,启动时会**自动启动 embedded Redis**(端口 6379)
-- 如果本地已有 Redis 运行,则直接使用外部 Redis
+- **sys_user** — 个人账号,登录时用用户名或手机号 + 密码
+- **sys_role** — 审批角色(纯权限,不登录),通过 `sys_user_role` 关联到用户
+- 菜单和接口权限由 `sys_user.employeeType` 控制
 
-### 构建打包
-
-```bash
-mvn clean package
-```
-
-打包后的 JAR 位于 `target/qqflowengine-backend-1.0.0.jar`,可直接运行:
+## 构建部署
 
 ```bash
+mvn clean package -DskipTests
 java -jar target/qqflowengine-backend-1.0.0.jar
 ```
 
@@ -80,23 +61,19 @@ java -jar target/qqflowengine-backend-1.0.0.jar
 
 ```
 src/main/java/com/qqflow/engine/
-├── common/          # 通用工具、异常、结果封装
-├── config/          # Spring 配置类
+├── common/             # 公共组件(Result、RedisCache、RedissonService、ExcelParseService、PageRequest)
+├── config/             # Spring 配置 + Security(JWT、CORS)
 ├── domain/
-│   ├── flow/        # 流程引擎模块(定义、实例、任务、审批)
-│   └── system/      # 系统管理模块(用户、角色、菜单、部门)
+│   ├── flow/           # 流程引擎(定义、实例、任务、审批记录、分析、导入)
+│   ├── system/         # 用户、角色、部门、认证
+│   └── ewechat/        # 企业微信回调集成
 └── QqFlowEngineApplication.java
 
 src/main/resources/
-├── application.yml       # 默认配置
-├── application-dev.yml   # 开发配置
-├── application-test.yml  # 测试配置
-├── schema-dev.sql        # 开发环境表结构
-├── data-dev.sql          # 开发环境初始数据
-└── mapper/               # MyBatis XML 映射文件
+├── application.yml / application-dev.yml / application-test.yml
+├── schema-mysql.sql / data-mysql.sql    # MySQL 初始化
+├── schema-dev.sql / data-dev.sql        # H2 开发初始化
+├── mapper/                              # MyBatis XML
+└── migration-*.sql                      # 增量迁移脚本
 ```
 
-## 注意事项
-
-1. `data/` 目录和 `target/` 目录已加入 `.gitignore`,不会被提交
-2. 如需切换 MySQL,修改 `pom.xml` 中 H2 的 scope 并配置 `application.yml` 即可

+ 1 - 0
docker-compose.yml

@@ -11,6 +11,7 @@ services:
       - qqflow-network
     environment:
       SPRING_PROFILES_ACTIVE: test
+      JWT_SECRET: qqflow-test-secret-key-2024-min-32chars
       SPRING_DATASOURCE_URL: jdbc:mysql://192.168.1.139:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai&character_set_server=utf8mb4
       SPRING_DATASOURCE_USERNAME: qqflowengine
       SPRING_DATASOURCE_PASSWORD: 123456

+ 30 - 6
pom.xml

@@ -38,6 +38,16 @@
             <artifactId>spring-boot-starter-data-redis</artifactId>
         </dependency>
         <dependency>
+            <groupId>org.redisson</groupId>
+            <artifactId>redisson-spring-boot-starter</artifactId>
+            <version>3.27.2</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-all</artifactId>
+            <version>${hutool.version}</version>
+        </dependency>
+        <dependency>
             <groupId>com.baomidou</groupId>
             <artifactId>mybatis-plus-boot-starter</artifactId>
             <version>${mybatis-plus.version}</version>
@@ -96,14 +106,28 @@
             <version>1.4.3</version>
         </dependency>
         <dependency>
-            <groupId>org.apache.poi</groupId>
-            <artifactId>poi</artifactId>
-            <version>5.2.5</version>
+            <groupId>com.alibaba</groupId>
+            <artifactId>easyexcel</artifactId>
+            <version>3.3.4</version>
+        </dependency>
+
+        <!-- JSON解析 -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.dataformat</groupId>
+            <artifactId>jackson-dataformat-xml</artifactId>
         </dependency>
+        <!-- Jackson JSR310 时间模块 -->
         <dependency>
-            <groupId>org.apache.poi</groupId>
-            <artifactId>poi-ooxml</artifactId>
-            <version>5.2.5</version>
+            <groupId>com.fasterxml.jackson.datatype</groupId>
+            <artifactId>jackson-datatype-jsr310</artifactId>
         </dependency>
     </dependencies>
     <build>

+ 13 - 0
settings.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
+    <mirrors>
+        <mirror>
+            <id>aliyun</id>
+            <name>Aliyun Maven Mirror</name>
+            <url>https://maven.aliyun.com/repository/public</url>
+            <mirrorOf>central</mirrorOf>
+        </mirror>
+    </mirrors>
+</settings>

+ 9 - 0
src/main/java/com/qqflow/engine/common/PageRequest.java

@@ -0,0 +1,9 @@
+package com.qqflow.engine.common;
+
+import lombok.Data;
+
+@Data
+public class PageRequest {
+    private Integer pageNum = 1;
+    private Integer pageSize = 10;
+}

+ 31 - 0
src/main/java/com/qqflow/engine/common/assemble/CommonAssembler.java

@@ -0,0 +1,31 @@
+package com.qqflow.engine.common.assemble;
+
+import com.qqflow.engine.common.po.BaseModel;
+import com.qqflow.engine.common.po.BaseUpdateModel;
+import com.qqflow.engine.common.util.id.IdUtil;
+import lombok.SneakyThrows;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author WuWenYi
+ * @since 2024/7/3
+ */
+public abstract class CommonAssembler {
+    @SneakyThrows
+    public <T extends BaseUpdateModel> T assembleCommon(Class<T> clazz) {
+        T t = clazz.newInstance();
+        t.setId(IdUtil.genId());
+        t.setCreatedTime(LocalDateTime.now());
+        t.setUpdatedTime(LocalDateTime.now());
+        return t;
+    }
+
+    @SneakyThrows
+    public <T extends BaseModel> T assembleCommonModel(Class<T> clazz) {
+        T t = clazz.newInstance();
+        t.setId(IdUtil.genId());
+        t.setCreatedTime(LocalDateTime.now());
+        return t;
+    }
+}

+ 47 - 0
src/main/java/com/qqflow/engine/common/config/LoggingInterceptor.java

@@ -0,0 +1,47 @@
+package com.qqflow.engine.common.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.http.client.ClientHttpResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Slf4j
+public class LoggingInterceptor implements ClientHttpRequestInterceptor {
+
+    @Override
+    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
+            ClientHttpRequestExecution execution) throws IOException {
+        // 记录请求日志
+        logRequest(request, body);
+
+        // 执行请求
+        ClientHttpResponse response = execution.execute(request, body);
+
+        // 记录响应日志
+        logResponse(response);
+
+        return response;
+    }
+
+    private void logRequest(HttpRequest request, byte[] body) {
+        log.info("URI: {}, Method: {}, Headers: {}", request.getURI(), request.getMethod(), request.getHeaders());
+        log.info("Body: {}", new String(body, StandardCharsets.UTF_8));
+    }
+
+    private void logResponse(ClientHttpResponse response) throws IOException {
+        byte[] bytes = new byte[4096];
+        int read = response.getBody().read(bytes);
+        if (read > 0) {
+            String logInfo = new String(bytes, 0, read, StandardCharsets.UTF_8);
+            log.info("status:{}, Body: {}", response.getStatusCode(), logInfo);
+        }
+    }
+}

+ 21 - 0
src/main/java/com/qqflow/engine/common/config/RestTemplateConfig.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.BufferingClientHttpRequestFactory;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * @author WuWenYi
+ * @since 2025/4/24
+ */
+@Configuration
+public class RestTemplateConfig {
+    @Bean
+    public RestTemplate restTemplate() {
+        RestTemplate restTemplate = new RestTemplate(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()));
+        restTemplate.getInterceptors().add(new LoggingInterceptor());
+        return restTemplate;
+    }
+}

+ 4 - 3
src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java

@@ -9,9 +9,7 @@ public final class SecurityConstants {
     }
 
     public static final String USER_TYPE_SYSTEM = "SYSTEM";
-    public static final String USER_TYPE_ROLE = "ROLE";
-
-    public static final String LOGIN_TYPE_ROLE = "ROLE";
+    public static final String USER_TYPE_ROLE = "ROLE"; // 保留常量,但岗位登录已移除,不再使用
 
     public static final String ASSIGNEE_TYPE_ROLE = "ROLE";
     public static final String ASSIGNEE_TYPE_USER = "USER";
@@ -21,4 +19,7 @@ public final class SecurityConstants {
     public static final String ROLE_DEPT_MANAGER = "dept_manager";
 
     public static final String EMPLOYEE_TYPE_COMMON_USER = "common_user";
+    public static final String EMPLOYEE_TYPE_FLOW_MANAGER = "flow_manager";
+    public static final String EMPLOYEE_TYPE_DEPT_MANAGER = "dept_manager";
+    public static final String EMPLOYEE_TYPE_SUPER_ADMIN = "super_admin";
 }

+ 93 - 0
src/main/java/com/qqflow/engine/common/controller/FileDownloadController.java

@@ -0,0 +1,93 @@
+package com.qqflow.engine.common.controller;
+
+import com.qqflow.engine.domain.flow.mapper.AttachmentMapper;
+import com.qqflow.engine.domain.flow.po.Attachment;
+import com.qqflow.engine.domain.flow.service.ProcessInstanceService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class FileDownloadController {
+
+    private static final String UPLOAD_DIR = "uploads";
+
+    private final AttachmentMapper attachmentMapper;
+    private final ProcessInstanceService processInstanceService;
+
+    @GetMapping("/uploads/{date}/{filename}")
+    public void download(@PathVariable String date,
+                         @PathVariable String filename,
+                         HttpServletRequest request,
+                         HttpServletResponse response) throws IOException {
+        // 基本路径校验,防止目录遍历
+        if (!isValidDate(date) || !isValidFilename(filename)) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "非法文件路径");
+            return;
+        }
+
+        String fileUrl = "/uploads/" + date + "/" + filename;
+        Attachment attachment = this.attachmentMapper.selectByFileUrl(fileUrl);
+        if (attachment == null) {
+            // 不暴露文件是否存在,统一返回 403
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该文件");
+            return;
+        }
+
+        // 校验当前登录用户对所属流程实例的可见性
+        try {
+            this.processInstanceService.checkInstanceVisibility(attachment.getInstanceId());
+        } catch (Exception e) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该文件");
+            return;
+        }
+
+        Path filePath = Paths.get(UPLOAD_DIR, date, filename);
+        if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
+            return;
+        }
+
+        // 校验请求路径与真实文件路径一致,防止通过编码绕过
+        Path normalizedPath = filePath.toAbsolutePath().normalize();
+        Path basePath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
+        if (!normalizedPath.startsWith(basePath)) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "非法文件路径");
+            return;
+        }
+
+        String contentType = request.getServletContext().getMimeType(filename);
+        if (!StringUtils.hasText(contentType)) {
+            contentType = "application/octet-stream";
+        }
+        response.setContentType(contentType);
+        response.setHeader("Content-Disposition", "inline; filename=\"" + filename + "\"");
+        Files.copy(filePath, response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
+    private boolean isValidDate(String date) {
+        return date != null && date.matches("\\d{6}");
+    }
+
+    private boolean isValidFilename(String filename) {
+        return filename != null
+                && !filename.isBlank()
+                && !filename.contains("..")
+                && !filename.contains("/")
+                && !filename.contains("\\")
+                && filename.matches("^[a-f0-9\\-]+\\.[a-zA-Z0-9]+$");
+    }
+}

+ 0 - 44
src/main/java/com/qqflow/engine/common/controller/FileUploadController.java

@@ -1,16 +1,10 @@
 package com.qqflow.engine.common.controller;
 
 import com.qqflow.engine.common.Result;
-import com.qqflow.engine.common.service.ExcelParseService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.MediaType;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -25,19 +19,15 @@ import java.nio.file.StandardCopyOption;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.Locale;
-import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 
 @RestController
 @RequestMapping("/file")
 @Tag(name = "文件上传")
-@PreAuthorize("isAuthenticated()")
 @RequiredArgsConstructor
 public class FileUploadController {
 
-    private final ExcelParseService excelParseService;
-
     private static final String UPLOAD_DIR = "uploads";
 
     private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
@@ -69,7 +59,6 @@ public class FileUploadController {
         if (originalFilename == null || originalFilename.isBlank()) {
             return Result.error("文件名不能为空");
         }
-        // 防止路径穿越
         if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")) {
             return Result.error("文件名包含非法字符");
         }
@@ -108,37 +97,4 @@ public class FileUploadController {
         }
         return filename.substring(lastDot + 1);
     }
-
-    @PostMapping(value = "/parse-excel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
-    @Operation(summary = "解析 Excel 表单数据")
-    public Result<Map<String, Object>> parseExcel(
-            @RequestParam("file") MultipartFile file,
-            @RequestParam("definitionId") Long definitionId,
-            @RequestParam("mappings") String mappingsJson) {
-        try {
-            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
-            Map<String, String> mappings = mapper.readValue(mappingsJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
-            Map<String, Object> formData = this.excelParseService.parseExcel(file, definitionId, mappings);
-            return Result.ok(formData);
-        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
-            return Result.error("字段映射格式错误");
-        } catch (Exception e) {
-            return Result.error(e.getMessage());
-        }
-    }
-
-    @GetMapping("/template/{definitionId}")
-    @Operation(summary = "下载流程表单 Excel 模板")
-    public void downloadTemplate(@PathVariable Long definitionId, HttpServletResponse response) {
-        try {
-            byte[] bytes = this.excelParseService.generateTemplate(definitionId);
-            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
-            response.setHeader("Content-Disposition", "attachment; filename=template.xlsx");
-            response.setContentLength(bytes.length);
-            response.getOutputStream().write(bytes);
-            response.getOutputStream().flush();
-        } catch (Exception e) {
-            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-        }
-    }
 }

+ 14 - 0
src/main/java/com/qqflow/engine/common/enums/StringIEnum.java

@@ -0,0 +1,14 @@
+package com.qqflow.engine.common.enums;
+
+import com.baomidou.mybatisplus.annotation.IEnum;
+
+/**
+ * 用于Mybatis-plus entity的实体类枚举映射
+ * @author WuWenYi
+ * @since 2024/6/5
+ */
+public interface StringIEnum extends IEnum<String> {
+    default String getValue() {
+        return toString();
+    }
+}

+ 31 - 0
src/main/java/com/qqflow/engine/common/po/BaseModel.java

@@ -0,0 +1,31 @@
+package com.qqflow.engine.common.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.Date;
+
+/**
+ * @author WuWenYi
+ * @since 2024/4/23
+ */
+@Getter
+@Setter
+public abstract class BaseModel implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.INPUT)
+    private Long id;
+
+    /**
+     * 创建时间
+     */
+    @TableField(value = "created_time")
+    private LocalDateTime createdTime;
+
+}

+ 20 - 0
src/main/java/com/qqflow/engine/common/po/BaseUpdateModel.java

@@ -0,0 +1,20 @@
+package com.qqflow.engine.common.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+import java.util.Date;
+
+/**
+ * @author WuWenYi
+ * @since 2024/4/23
+ */
+@Getter
+@Setter
+public abstract class BaseUpdateModel extends BaseModel {
+
+    @TableField(value = "updated_time")
+    private LocalDateTime updatedTime;
+}

+ 163 - 125
src/main/java/com/qqflow/engine/common/service/ExcelParseService.java

@@ -1,26 +1,22 @@
 package com.qqflow.engine.common.service;
 
+import com.alibaba.excel.EasyExcel;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.domain.flow.mapper.ProcessDefinitionMapper;
+import com.qqflow.engine.domain.flow.model.FlowModel;
+import com.qqflow.engine.domain.flow.model.FlowNode;
 import com.qqflow.engine.domain.flow.po.ProcessDefinition;
+import com.qqflow.engine.domain.system.entity.SysUser;
+import com.qqflow.engine.domain.system.mapper.SysUserMapper;
 import lombok.RequiredArgsConstructor;
-import org.apache.poi.hssf.usermodel.HSSFWorkbook;
-import org.apache.poi.ss.usermodel.Cell;
-import org.apache.poi.ss.usermodel.CellType;
-import org.apache.poi.ss.usermodel.DateUtil;
-import org.apache.poi.ss.usermodel.Row;
-import org.apache.poi.ss.usermodel.Sheet;
-import org.apache.poi.ss.usermodel.Workbook;
-import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
+import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -33,15 +29,19 @@ public class ExcelParseService {
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     private final ProcessDefinitionMapper processDefinitionMapper;
+    private final SysUserMapper sysUserMapper;
 
-    /**
-     * 根据字段映射关系解析 Excel,返回表单数据 Map。
-     *
-     * @param file         Excel 文件
-     * @param definitionId 流程定义 ID
-     * @param mappings     Excel 列名 -> 系统字段名 的映射
-     */
+    /** 单行解析(兼容旧接口) */
     public Map<String, Object> parseExcel(MultipartFile file, Long definitionId, Map<String, String> mappings) {
+        List<Map<String, Object>> rows = this.parseExcelRows(file, definitionId, mappings);
+        if (rows.isEmpty()) {
+            throw new BusinessException("Excel 无有效数据");
+        }
+        return rows.get(0);
+    }
+
+    /** 批量解析 Excel,返回每行数据的 Map 列表 */
+    public List<Map<String, Object>> parseExcelRows(MultipartFile file, Long definitionId, Map<String, String> mappings) {
         if (file == null || file.isEmpty()) {
             throw new BusinessException("文件不能为空");
         }
@@ -53,93 +53,178 @@ public class ExcelParseService {
             throw new BusinessException("流程定义不存在");
         }
 
-        try (InputStream is = file.getInputStream();
-             Workbook workbook = createWorkbook(is, file.getOriginalFilename())) {
-            Sheet sheet = workbook.getSheetAt(0);
-            if (sheet.getPhysicalNumberOfRows() < 2) {
+        try {
+            List<Object> rawRows = EasyExcel.read(file.getInputStream()).headRowNumber(0).sheet(0).doReadSync();
+            if (rawRows.size() < 2) {
                 throw new BusinessException("Excel 至少需要包含表头和一行数据");
             }
-            Row headerRow = sheet.getRow(0);
-            if (headerRow == null) {
-                throw new BusinessException("Excel 表头为空");
-            }
 
-            // 读取表头:列索引 -> Excel 列名
-            Map<Integer, String> columnIndexMap = new HashMap<>();
-            for (int i = 0; i < headerRow.getLastCellNum(); i++) {
-                Cell cell = headerRow.getCell(i);
-                if (cell == null) continue;
-                String value = getCellStringValue(cell);
-                if (value != null && !value.isBlank()) {
-                    columnIndexMap.put(i, value.trim());
+            @SuppressWarnings("unchecked")
+            Map<Integer, Object> headerRow = (Map<Integer, Object>) rawRows.get(0);
+
+            String nodeFieldName = definition.getNodeFieldName();
+            String ownerFieldName = definition.getOwnerFieldName();
+            String bizFieldName = definition.getBizFieldName();
+
+            List<Map<String, Object>> results = new ArrayList<>();
+            // 从第二行开始(索引 1)遍历数据行
+            for (int rowIdx = 1; rowIdx < rawRows.size(); rowIdx++) {
+                @SuppressWarnings("unchecked")
+                Map<Integer, Object> dataRow = (Map<Integer, Object>) rawRows.get(rowIdx);
+                // 跳过全空行
+                if (dataRow.values().stream().allMatch(v -> v == null || String.valueOf(v).trim().isEmpty())) continue;
+
+                Map<String, Object> formData = new LinkedHashMap<>();
+                String nodeName = null;
+                String ownerName = null;
+                String bizValue = null;
+
+                for (Map.Entry<Integer, Object> entry : headerRow.entrySet()) {
+                    int col = entry.getKey();
+                    String excelColumn = entry.getValue() != null
+                            ? String.valueOf(entry.getValue()).replace("\r\n", " ").replace("\r", " ").replace("\n", " ").trim()
+                            : "";
+                    if (excelColumn.isEmpty()) continue;
+
+                    Object cellValue = dataRow.get(col);
+                    String value = cellValue == null ? "" : String.valueOf(cellValue).trim();
+                    String systemField = mappings.get(excelColumn);
+
+                    if (nodeFieldName != null && !nodeFieldName.isBlank() && excelColumn.equals(nodeFieldName)) {
+                        nodeName = value;
+                    }
+                    if (ownerFieldName != null && !ownerFieldName.isBlank() && excelColumn.equals(ownerFieldName)) {
+                        ownerName = value;
+                    }
+                    if (systemField != null && !systemField.isBlank()) {
+                        formData.put(systemField, value);
+                    }
+                    if (bizFieldName != null && !bizFieldName.isBlank() && excelColumn.equals(bizFieldName)) {
+                        bizValue = value;
+                        formData.put(bizFieldName, value);
+                    }
                 }
-            }
 
-            // 读取第一行数据
-            Row dataRow = sheet.getRow(1);
-            if (dataRow == null) {
-                throw new BusinessException("Excel 数据行为空");
-            }
+                Long ownerId = null;
+                if (ownerName != null && !ownerName.isBlank()) {
+                    SysUser user = sysUserMapper.selectByRealName(ownerName.trim());
+                    if (user != null) {
+                        ownerId = user.getId();
+                    }
+                }
 
-            Map<String, Object> formData = new LinkedHashMap<>();
-            for (Map.Entry<Integer, String> entry : columnIndexMap.entrySet()) {
-                String excelColumn = entry.getValue();
-                String systemField = mappings.get(excelColumn);
-                if (systemField == null || systemField.isBlank()) {
-                    continue;
+                // 提取所有节点的审批时间字段(一行可能对应多个节点的时间)
+                Map<String, String> nodeApproveTimes = new LinkedHashMap<>();
+                FlowModel flowModel = parseFlowModel(definition);
+                if (flowModel != null && flowModel.getNodes() != null) {
+                    for (FlowNode n : flowModel.getNodes()) {
+                        if (n.getProperties() == null) continue;
+                        Object atf = n.getProperties().get("approveTimeField");
+                        if (atf == null || atf.toString().isBlank()) continue;
+                        for (Map.Entry<Integer, Object> entry : headerRow.entrySet()) {
+                            String col = normalizeHeader(entry.getValue());
+                            if (col.equals(atf.toString().trim())) {
+                                Object v = dataRow.get(entry.getKey());
+                                nodeApproveTimes.put(n.getName(), toDateString(v));
+                                break;
+                            }
+                        }
+                    }
                 }
-                Cell cell = dataRow.getCell(entry.getKey());
-                Object value = getCellValue(cell);
-                formData.put(systemField, value);
+
+                Map<String, Object> rowResult = new LinkedHashMap<>();
+                rowResult.put("formData", formData);
+                rowResult.put("nodeName", nodeName);
+                rowResult.put("ownerId", ownerId);
+                rowResult.put("ownerName", ownerName);
+                rowResult.put("bizValue", bizValue);
+                rowResult.put("nodeApproveTimes", nodeApproveTimes);
+                results.add(rowResult);
             }
-            return formData;
+            return results;
         } catch (IOException e) {
             throw new BusinessException("Excel 解析失败: " + e.getMessage());
         }
     }
 
-    /**
-     * 生成 Excel 模板文件字节数组
-     */
+    /** 生成 Excel 模板 */
     public byte[] generateTemplate(Long definitionId) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(definitionId);
         if (definition == null) {
             throw new BusinessException("流程定义不存在");
         }
-        String formSchema = definition.getFormSchema();
-        List<Map<String, Object>> fields = parseFormSchema(formSchema);
-
-        try (Workbook workbook = new XSSFWorkbook()) {
-            Sheet sheet = workbook.createSheet("模板");
-            // 表头行
-            Row headerRow = sheet.createRow(0);
-            for (int i = 0; i < fields.size(); i++) {
-                Map<String, Object> field = fields.get(i);
-                String label = Objects.toString(field.get("label"), "");
-                String name = Objects.toString(field.get("name"), "");
-                Cell cell = headerRow.createCell(i);
-                cell.setCellValue(label + "(" + name + ")");
-            }
-            // 示例数据行
-            Row exampleRow = sheet.createRow(1);
-            for (int i = 0; i < fields.size(); i++) {
-                Cell cell = exampleRow.createCell(i);
-                cell.setCellValue("示例值");
+        List<Map<String, Object>> fields = parseFormSchema(definition.getFormSchema());
+        String nodeFieldName = definition.getNodeFieldName();
+        String ownerFieldName = definition.getOwnerFieldName();
+
+        List<List<String>> head = new ArrayList<>();
+        java.util.Set<String> usedHeaders = new java.util.HashSet<>();
+        if (nodeFieldName != null && !nodeFieldName.isBlank()) {
+            head.add(Collections.singletonList(nodeFieldName));
+            usedHeaders.add(nodeFieldName);
+        }
+        if (ownerFieldName != null && !ownerFieldName.isBlank()) {
+            if (!usedHeaders.add(ownerFieldName)) {
+                throw new BusinessException("模板表头重复:" + ownerFieldName + ",请检查流程定义的节点字段/负责人字段配置");
             }
-            // 自动调整列宽
-            for (int i = 0; i < fields.size(); i++) {
-                sheet.autoSizeColumn(i);
+            head.add(Collections.singletonList(ownerFieldName));
+        }
+        for (Map<String, Object> field : fields) {
+            String label = Objects.toString(field.get("label"), Objects.toString(field.get("name"), ""));
+            if (label.isBlank()) continue;
+            if (!usedHeaders.add(label)) {
+                throw new BusinessException("模板表头重复:" + label + ",请检查表单字段 label 是否与节点字段/负责人字段同名");
             }
+            head.add(Collections.singletonList(label));
+        }
 
-            try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
-                workbook.write(bos);
-                return bos.toByteArray();
-            }
+        List<List<String>> data = new ArrayList<>();
+        List<String> exampleRow = new ArrayList<>();
+        for (int i = 0; i < head.size(); i++) {
+            exampleRow.add("示例值");
+        }
+        data.add(exampleRow);
+
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+            EasyExcel.write(bos).head(head).sheet("模板").doWrite(data);
+            return bos.toByteArray();
         } catch (IOException e) {
             throw new BusinessException("模板生成失败: " + e.getMessage());
         }
     }
 
+    private FlowModel parseFlowModel(ProcessDefinition definition) {
+        try {
+            return OBJECT_MAPPER.readValue(definition.getModelJson(), FlowModel.class);
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private String normalizeHeader(Object value) {
+        if (value == null) return "";
+        return String.valueOf(value).replace("\r\n", " ").replace("\r", " ").replace("\n", " ").trim();
+    }
+
+    /** 将 Excel 单元格值转为日期字符串(处理数值型日期) */
+    private String toDateString(Object cellValue) {
+        if (cellValue == null) return null;
+        if (cellValue instanceof java.util.Date) {
+            return java.time.LocalDateTime.ofInstant(((java.util.Date) cellValue).toInstant(),
+                    java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        }
+        if (cellValue instanceof Number) {
+            // Excel 日期序列号(1900-01-01 = 1)
+            double days = ((Number) cellValue).doubleValue();
+            if (days > 1 && days < 100000) {
+                java.time.LocalDateTime base = java.time.LocalDateTime.of(1899, 12, 30, 0, 0, 0);
+                return base.plusDays((long) days).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+            }
+        }
+        String s = String.valueOf(cellValue).trim();
+        return s.isEmpty() ? null : s;
+    }
+
     @SuppressWarnings("unchecked")
     private List<Map<String, Object>> parseFormSchema(String formSchema) {
         if (formSchema == null || formSchema.isBlank()) {
@@ -151,51 +236,4 @@ public class ExcelParseService {
             throw new BusinessException("表单字段定义格式错误");
         }
     }
-
-    private Workbook createWorkbook(InputStream is, String filename) throws IOException {
-        if (filename != null && filename.toLowerCase().endsWith(".xls")) {
-            return new HSSFWorkbook(is);
-        }
-        return new XSSFWorkbook(is);
-    }
-
-    private String getCellStringValue(Cell cell) {
-        if (cell == null) return "";
-        return switch (cell.getCellType()) {
-            case STRING -> cell.getStringCellValue();
-            case NUMERIC -> {
-                if (DateUtil.isCellDateFormatted(cell)) {
-                    yield cell.getLocalDateTimeCellValue().toString();
-                }
-                double num = cell.getNumericCellValue();
-                if (num == Math.rint(num)) {
-                    yield String.valueOf((long) num);
-                }
-                yield String.valueOf(num);
-            }
-            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
-            case FORMULA -> cell.getCellFormula();
-            default -> "";
-        };
-    }
-
-    private Object getCellValue(Cell cell) {
-        if (cell == null) return "";
-        return switch (cell.getCellType()) {
-            case STRING -> cell.getStringCellValue();
-            case NUMERIC -> {
-                if (DateUtil.isCellDateFormatted(cell)) {
-                    yield cell.getLocalDateTimeCellValue().toString();
-                }
-                double num = cell.getNumericCellValue();
-                if (num == Math.rint(num)) {
-                    yield (long) num;
-                }
-                yield num;
-            }
-            case BOOLEAN -> cell.getBooleanCellValue();
-            case FORMULA -> getCellStringValue(cell);
-            default -> "";
-        };
-    }
 }

+ 176 - 0
src/main/java/com/qqflow/engine/common/service/RedissonService.java

@@ -0,0 +1,176 @@
+package com.qqflow.engine.common.service;
+
+import org.redisson.api.*;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class RedissonService {
+
+    private final RedissonClient redissonClient;
+
+    public RedissonService(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    // ==================== 分布式锁 ====================
+
+    /** 获取锁(阻塞直到获取成功) */
+    public RLock getLock(String key) {
+        return redissonClient.getLock(key);
+    }
+
+    /** 尝试获取锁,默认等待 10 秒,持有 30 秒 */
+    public boolean tryLock(String key) {
+        try {
+            return redissonClient.getLock(key).tryLock(10, 30, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    /** 尝试获取锁 */
+    public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit) {
+        try {
+            return redissonClient.getLock(key).tryLock(waitTime, leaseTime, unit);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return false;
+        }
+    }
+
+    /** 释放锁 */
+    public void unlock(String key) {
+        RLock lock = redissonClient.getLock(key);
+        if (lock.isHeldByCurrentThread()) {
+            lock.unlock();
+        }
+    }
+
+    public void unlock(RLock lock) {
+        if (lock != null && lock.isHeldByCurrentThread()) {
+            lock.unlock();
+        }
+    }
+
+    /** 是否被锁 */
+    public boolean isLocked(String key) {
+        return redissonClient.getLock(key).isLocked();
+    }
+
+    // ==================== 通用 Key-Value ====================
+
+    public <T> void set(String key, T value) {
+        redissonClient.getBucket(key).set(value);
+    }
+
+    public <T> void set(String key, T value, long ttl, TimeUnit unit) {
+        redissonClient.getBucket(key).set(value, ttl, unit);
+    }
+
+    public <T> void set(String key, T value, Duration ttl) {
+        redissonClient.getBucket(key).set(value, ttl);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <T> T get(String key) {
+        RBucket<T> bucket = redissonClient.getBucket(key);
+        return bucket.get();
+    }
+
+    public boolean delete(String key) {
+        return redissonClient.getBucket(key).delete();
+    }
+
+    public boolean exists(String key) {
+        return redissonClient.getBucket(key).isExists();
+    }
+
+    /** 设置过期时间 */
+    public boolean expire(String key, long ttl, TimeUnit unit) {
+        return redissonClient.getBucket(key).expire(ttl, unit);
+    }
+
+    // ==================== 原子操作 ====================
+
+    public RAtomicLong getAtomicLong(String key) {
+        return redissonClient.getAtomicLong(key);
+    }
+
+    public long increment(String key) {
+        return redissonClient.getAtomicLong(key).incrementAndGet();
+    }
+
+    public long decrement(String key) {
+        return redissonClient.getAtomicLong(key).decrementAndGet();
+    }
+
+    // ==================== Map 操作 ====================
+
+    public <K, V> Map<K, V> getMap(String key) {
+        return redissonClient.<K, V>getMap(key);
+    }
+
+    public <K, V> void putMapValue(String key, K field, V value) {
+        redissonClient.<K, V>getMap(key).put(field, value);
+    }
+
+    public <K, V> V getMapValue(String key, K field) {
+        return redissonClient.<K, V>getMap(key).get(field);
+    }
+
+    // ==================== 限流器 ====================
+
+    public RRateLimiter getRateLimiter(String key) {
+        return redissonClient.getRateLimiter(key);
+    }
+
+    /** 设置限流速率并获取令牌 */
+    public boolean tryAcquireRateLimiter(String key, long rate, long rateInterval, RateIntervalUnit unit) {
+        RRateLimiter limiter = redissonClient.getRateLimiter(key);
+        limiter.trySetRate(RateType.OVERALL, rate, rateInterval, unit);
+        return limiter.tryAcquire();
+    }
+
+    // ==================== 队列操作 ====================
+
+    /** 获取阻塞队列 */
+    public <T> RBlockingQueue<T> getBlockingQueue(String key) {
+        return redissonClient.getBlockingQueue(key);
+    }
+
+    /** 入队 */
+    public <T> boolean offer(String key, T value) {
+        return redissonClient.<T>getQueue(key).offer(value);
+    }
+
+    /** 出队 */
+    public <T> T poll(String key) {
+        return redissonClient.<T>getQueue(key).poll();
+    }
+
+    // ==================== Set 操作 ====================
+
+    public boolean sAdd(String key, String value) {
+        return redissonClient.<String>getSet(key).add(value);
+    }
+
+    public boolean sContains(String key, String value) {
+        return redissonClient.<String>getSet(key).contains(value);
+    }
+
+    public Collection<String> sMembers(String key) {
+        return redissonClient.<String>getSet(key).readAll();
+    }
+
+    // ==================== 底层客户端 ====================
+
+    public RedissonClient getClient() {
+        return redissonClient;
+    }
+}

+ 70 - 0
src/main/java/com/qqflow/engine/common/util/Assert.java

@@ -0,0 +1,70 @@
+package com.qqflow.engine.common.util;
+
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.common.exception.BusinessException;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 业务断言工具类
+ * @author WuWenYi
+ * @since 2023/11/30
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class Assert {
+    public static void isTure(boolean condition, String s) {
+        if (!condition) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void isFalse(boolean condition, String s) {
+        if (condition) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void notNull(Object target, String s) {
+        if (Objects.isNull(target)) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void isNull(Object target, String s) {
+        if (Objects.nonNull(target)) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void notEmpty(Collection<?> collection, String s) {
+        if (collection == null || collection.isEmpty()) {
+            throw new BusinessException(s);
+        }
+    }
+
+
+    public static void notBlank(String target, String s) {
+        if (StringUtils.isBlank(target)) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void equals(Object target, Object target2, String s) {
+        if (!Objects.equals(target, target2)) {
+            throw new BusinessException(s);
+        }
+    }
+
+    public static void notEquals(Object target, Object target2, String s) {
+        if (Objects.equals(target, target2)) {
+            throw new BusinessException(s);
+        }
+    }
+
+}
+

+ 546 - 0
src/main/java/com/qqflow/engine/common/util/JsonUtil.java

@@ -0,0 +1,546 @@
+package com.qqflow.engine.common.util;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.*;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+import com.qqflow.engine.common.exception.BusinessException;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @author chenanlian
+ */
+public class JsonUtil {
+
+    private static Logger log = LoggerFactory.getLogger(JsonUtil.class);
+
+    private static volatile ObjectMapper objectMapper;
+
+
+    /**
+     * 获取单例ObjectMapper
+     *
+     * @return
+     */
+    public static ObjectMapper getObjectMapper() {
+        if (objectMapper == null) {
+            synchronized (JsonUtil.class) {
+                if (objectMapper == null) {
+                    objectMapper = new ObjectMapper();
+                    objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
+                    objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
+                    objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+                    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+                }
+            }
+        }
+        return objectMapper;
+    }
+
+    /**
+     * 对象转json字符串
+     *
+     * @param object
+     * @return
+     */
+    public static String entity2Json(Object object) {
+        try {
+            return getObjectMapper().writeValueAsString(object);
+        } catch (JsonProcessingException e) {
+            log.error(e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * 将json形式字符串转换为java实体类
+     */
+    public static <T> T str2Entity(String jsonStr, TypeReference<T> clazz) {
+        if (StringUtils.isBlank(jsonStr)) {
+            return null;
+        }
+        T readValue = null;
+        try {
+            readValue = getObjectMapper().readValue(jsonStr, clazz);
+        } catch (JsonParseException e) {
+            log.error(e.getMessage(), e);
+        } catch (JsonMappingException e) {
+            log.error(e.getMessage(), e);
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        }
+        return readValue;
+    }
+
+    /**
+     * 将json形式字符串转换为java实体类
+     */
+    public static <T> T str2Entity(String jsonStr, Class<T> clazz) {
+        T readValue = null;
+        try {
+            readValue = getObjectMapper().readValue(jsonStr, clazz);
+        } catch (JsonParseException e) {
+            log.error(e.getMessage(), e);
+        } catch (JsonMappingException e) {
+            log.error(e.getMessage(), e);
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        }
+        return readValue;
+    }
+
+
+    public static <T, T1> T str2Entity(String jsonStr, Class<T> clazz, Class<T1> clazz2) {
+        T readValue = null;
+        try {
+            JavaType javaType1 = JsonUtil.getObjectMapper().getTypeFactory().constructParametricType(clazz, clazz2);
+            readValue = JsonUtil.getObjectMapper().readValue(jsonStr, javaType1);
+        } catch (JsonParseException e) {
+            log.error(e.getMessage(), e);
+        } catch (JsonMappingException e) {
+            log.error(e.getMessage(), e);
+        } catch (IOException e) {
+            log.error(e.getMessage(), e);
+        }
+        return readValue;
+    }
+
+
+    public static <T, T1> T str2Entity(String jsonStr, String dateFormat, Class<T> clazz, Class<T1> clazz2) {
+        T readValue = null;
+        try {
+            ObjectMapper mapper = getObjectMapper();
+            if (dateFormat != null) {
+                mapper = new ObjectMapper();
+                mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
+                mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+                mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+                mapper.setDateFormat(new SimpleDateFormat(dateFormat));
+            }
+
+            JavaType javaType1 = mapper.getTypeFactory().constructParametricType(clazz, clazz2);
+            readValue = mapper.readValue(jsonStr, javaType1);
+        } catch (JsonParseException e) {
+            log.error(e.getMessage(), e);
+        } catch (JsonMappingException e) {
+            log.error(e.getMessage(), e);
+        } catch (IOException e) {
+            log.error(e.getMessage(), e);
+        }
+        return readValue;
+    }
+
+    public static <T> T str2Entity(String jsonStr, Class<?>... classes) {
+        T readValue = null;
+        try {
+            ObjectMapper mapper = getObjectMapper();
+            TypeFactory typeFactory = mapper.getTypeFactory();
+            if (classes.length < 2) {
+                throw new BusinessException("参数长度必须大于1");
+            }
+            JavaType javaType = typeFactory.constructType(classes[classes.length - 1]);
+            for (int i = classes.length - 1; i >= 1; i--) {
+                if (i - 2 >= 0 && classes[i - 2].equals(Map.class)) {
+                    javaType = typeFactory.constructParametricType(HashMap.class, typeFactory.constructType(classes[i - 1]), javaType);
+                    i--;
+                } else {
+                    javaType = typeFactory.constructParametricType(classes[i - 1], javaType);
+                }
+            }
+            readValue = mapper.readValue(jsonStr, javaType);
+        } catch (JsonProcessingException e) {
+            log.error(e.getMessage(), e);
+        }
+        return readValue;
+    }
+
+    /**
+     * jsonNode 转实体
+     *
+     * @param jsonNode
+     * @param clazz
+     * @param <T>
+     * @return
+     */
+    public static <T> T jsonNode2Entity(JsonNode jsonNode, Class<T> clazz) {
+        T readValue = getObjectMapper().convertValue(jsonNode, clazz);
+        return readValue;
+    }
+
+    /**
+     * 获取泛型的Collection Type
+     *
+     * @param collectionClass 泛型的Collection
+     * @param elementClasses  元素类
+     * @return JavaType Java类型
+     * @since 1.0
+     */
+    public static JavaType getCollectionType(Class<?> collectionClass, Class<?>... elementClasses) {
+        return getObjectMapper().getTypeFactory().constructParametricType(collectionClass, elementClasses);
+    }
+
+    /**
+     * json字符串转集合类
+     *
+     * @param jsonString
+     * @param collectionClass
+     * @param elementClasses
+     * @return
+     */
+    public static Object str2Collection(String jsonString, Class<?> collectionClass, Class<?>... elementClasses) {
+        JavaType javaType = getCollectionType(collectionClass, elementClasses);
+        try {
+            return getObjectMapper().readValue(jsonString, javaType);
+        } catch (IOException e) {
+            log.error(e.getMessage(), e);
+            return null;
+        }
+    }
+
+    public static Object jsonNode2Collection(JsonNode jsonString, Class<?> collectionClass, Class<?>... elementClasses) {
+        JavaType javaType = getCollectionType(collectionClass, elementClasses);
+        try {
+            return getObjectMapper().convertValue(jsonString, javaType);
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * jsonNode 转字符串并按字母排序
+     *
+     * @param node
+     * @return
+     */
+    public static String jsonNode2SortStr(final JsonNode node) {
+        Object obj;
+        String json = null;
+        try {
+            obj = objectMapper.treeToValue(node, Object.class);
+            json = objectMapper.writeValueAsString(obj);
+        } catch (JsonProcessingException e) {
+            log.error(e.getMessage(), e);
+        }
+        return json;
+    }
+
+    /**
+     * 字符串转JsonNode
+     *
+     * @param str
+     * @return
+     */
+    public static JsonNode str2JsonNode(String str) {
+        try {
+            return getObjectMapper().readTree(str);
+        } catch (IOException e) {
+            log.error(e.getMessage(), e);
+            return null;
+        }
+
+    }
+
+    public static JsonNode entity2JsonNode(Object entity) {
+        return objectMapper.convertValue(entity, JsonNode.class);
+    }
+
+
+    public static JsonNode map2JsonNode(Map<String, String> map) {
+
+        ObjectNode jsonNodes = getObjectMapper().createObjectNode();
+        for (Map.Entry<String, String> entry : map.entrySet()) {
+            jsonNodes.put(entry.getKey(), entry.getValue());
+        }
+        return jsonNodes;
+    }
+
+    /**
+     * JsonNode转Map,只取第一层,值转为字符串
+     *
+     * @param jsonNode
+     * @return
+     */
+    public static Map<String, String> jsonNode2MapStr(JsonNode jsonNode) {
+        Map<String, String> map = new HashMap<>();
+        if (jsonNode.isObject()) {
+            Iterator<Map.Entry<String, JsonNode>> it = jsonNode.fields();
+            while (it.hasNext()) {
+                Map.Entry<String, JsonNode> entry = it.next();
+                map.put(entry.getKey(), entry.getValue().asText(null));
+            }
+        }
+        return map;
+    }
+
+    /**
+     * JsonNode转Map,只取第一层
+     *
+     * @param jsonNode
+     * @return
+     */
+    public static Map<String, Object> jsonNode2Map(JsonNode jsonNode) {
+        Map<String, Object> map = new HashMap<>();
+        if (jsonNode.isObject()) {
+            Iterator<Map.Entry<String, JsonNode>> it = jsonNode.fields();
+            while (it.hasNext()) {
+                Map.Entry<String, JsonNode> entry = it.next();
+                map.put(entry.getKey(), entry.getValue());
+            }
+        }
+        return map;
+    }
+
+
+    /**
+     * 字符串转JsonNode
+     *
+     * @param str
+     * @return
+     */
+    public static JsonNode str2Node(String str) {
+        if (StringUtils.isBlank(str)) {
+            return null;
+        }
+        try {
+            return getObjectMapper().readTree(str);
+        } catch (IOException e) {
+            log.error(e.getMessage(), e);
+            return null;
+        }
+
+    }
+
+    public static void jsonLeaf(JsonNode node) {
+        if (node.isValueNode()) {
+            System.out.println(node.toString());
+            return;
+        }
+
+        if (node.isObject()) {
+            Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+            while (it.hasNext()) {
+                Map.Entry<String, JsonNode> entry = it.next();
+                jsonLeaf(entry.getValue());
+            }
+        }
+
+        if (node.isArray()) {
+            Iterator<JsonNode> it = node.iterator();
+            while (it.hasNext()) {
+                jsonLeaf(it.next());
+            }
+        }
+    }
+
+
+    public static List<String> getStrList(JsonNode jsonNode) {
+        List<String> listll = new ArrayList<>();
+        if (jsonNode.isArray()) {
+            for (JsonNode objNode : jsonNode) {
+                String value = objNode.asText();
+                listll.add(value);
+            }
+        }
+        return listll;
+    }
+
+    /**
+     * 将数据源jsonNode 按模板jsonNode输出sql语句列表
+     *
+     * @param data     数据来源
+     * @param template json 模板
+     * @return sql 语句列表
+     */
+    public static List<String> transform(JsonNode data, JsonNode template) {
+        List<String> sqlList = new ArrayList<>();
+        Iterator<String> tableIterator = template.fieldNames();
+        while (tableIterator.hasNext()) {
+            /*表*/
+            String tableName = tableIterator.next();
+            JsonNode tableNode = template.get(tableName);
+            Iterator<String> fieldIterator = tableNode.fieldNames();
+
+            StringBuilder sb = new StringBuilder();
+            StringBuilder sbv = new StringBuilder();
+            sb.append("INSERT INTO ");
+            sb.append(tableName);
+            sb.append(" (");
+            sbv.append(" VALUES (");
+
+            while (fieldIterator.hasNext()) {
+                /*字段*/
+                String fieldName = fieldIterator.next();
+                String fieldPath = tableNode.get(fieldName).asText();
+                JsonNode fileValue = findValueByPath(data, fieldPath);
+                /*去除没有值或取值错误的节点*/
+                if (null != fileValue && !fileValue.equals("")) {
+                    sb.append(fieldName);
+                    sb.append(",");
+                    sbv.append(fileValue);
+                    sbv.append(",");
+                }
+            }
+            sb.deleteCharAt(sb.length() - 1);
+            sbv.deleteCharAt(sbv.length() - 1);
+            sb.append(")");
+            sbv.append(")");
+            sb.append(sbv);
+
+            sqlList.add(sb.toString());
+        }
+        return sqlList;
+    }
+
+
+    /**
+     * 根据路径获取值
+     *
+     * @param jsonNode
+     * @param valuePath 节点路径
+     * @return
+     */
+    public static JsonNode findValueByPath(JsonNode jsonNode, String valuePath) {
+        String[] pathMutil = valuePath.split("\\.");
+        JsonNode jsonTem = jsonNode;
+        for (int i = 0; i < pathMutil.length; i++) {
+            jsonTem = jsonTem.path(pathMutil[i]);
+        }
+        return jsonTem;
+    }
+
+
+    /**
+     * 重组json
+     *
+     * @param jsonNode
+     * @param nodePath
+     * @return
+     */
+    public static JsonNode jsonNodeTransform(JsonNode jsonNode, List<String> nodePath) {
+        JsonNodeFactory factory = new JsonNodeFactory(false);
+        ObjectNode objectNode = factory.objectNode();
+        for (String path : nodePath) {
+            String[] pathMutil = path.split("\\.");
+            if (pathMutil.length > 1) {
+                JsonNode jsonTem = jsonNode;
+                for (int i = 0; i < pathMutil.length; i++) {
+                    jsonTem = jsonTem.path(pathMutil[i]);
+                }
+                objectNode.put(path, jsonTem);
+            } else {
+                JsonNode jsonNodeChild = jsonNode.path(path);
+                objectNode.put(path, jsonNodeChild);
+            }
+        }
+        return objectNode;
+    }
+
+    /**
+     * 获取字符串值
+     *
+     * @param jsonNode
+     * @param key
+     * @return
+     */
+    public static String getString(JsonNode jsonNode, String key) {
+        JsonNode value = jsonNode.get(key);
+        if (value == null) {
+            return null;
+        } else {
+            return value.asText().trim();
+        }
+    }
+
+    public static Integer getInt(JsonNode jsonNode, String key) {
+        JsonNode value = jsonNode.get(key);
+        if (value == null) {
+            return null;
+        } else {
+            return value.asInt();
+        }
+    }
+
+
+    /**
+     * 实体对象转成Map
+     *
+     * @param obj 实体对象
+     * @return
+     */
+    public static Map<String, Object> object2Map(Object obj) {
+        Map<String, Object> map = new HashMap<String, Object>();
+        if (obj == null) {
+            return map;
+        }
+        Class clazz = obj.getClass();
+        Field[] fields = clazz.getDeclaredFields();
+        try {
+            for (Field field : fields) {
+                field.setAccessible(true);
+                map.put(field.getName(), field.get(obj));
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return map;
+    }
+
+    /**
+     * Map转成实体对象
+     *
+     * @param map   map实体对象包含属性
+     * @param clazz 实体对象类型
+     * @return
+     */
+    public static <T> T map2Object(Map<String, Object> map, Class<T> clazz) {
+        if (map == null) {
+            return null;
+        }
+        T obj = null;
+        try {
+            obj = clazz.newInstance();
+
+            Field[] fields = obj.getClass().getDeclaredFields();
+            for (Field field : fields) {
+                int mod = field.getModifiers();
+                if (Modifier.isStatic(mod) || Modifier.isFinal(mod)) {
+                    continue;
+                }
+                field.setAccessible(true);
+                String filedTypeName = field.getType().getName();
+                if (filedTypeName.equalsIgnoreCase("java.util.date")) {
+                    String datetimestamp = String.valueOf(map.get(field.getName()));
+                    if (datetimestamp.equalsIgnoreCase("null")) {
+                        field.set(obj, null);
+                    } else {
+                        field.set(obj, new Date(Long.parseLong(datetimestamp)));
+                    }
+                } else {
+                    field.set(obj, map.get(field.getName()));
+                }
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return obj;
+    }
+
+
+
+}
+

+ 10 - 3
src/main/java/com/qqflow/engine/common/util/JwtUtils.java

@@ -9,6 +9,7 @@ import jakarta.annotation.PostConstruct;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
@@ -20,15 +21,21 @@ import java.util.UUID;
 @Component
 public class JwtUtils {
 
-    private static final String DEFAULT_SECRET = "qqflow-engine-secret-key-2024-very-long-and-secure";
+    private static final String OLD_DEFAULT_SECRET = "qqflow-engine-secret-key-2024-very-long-and-secure";
 
     @Value("${jwt.secret}")
     private String secret;
 
     @PostConstruct
     public void checkSecret() {
-        if (DEFAULT_SECRET.equals(secret)) {
-            log.warn("JWT secret is using default value; set JWT_SECRET env var in production");
+        if (!StringUtils.hasText(secret)) {
+            throw new IllegalStateException("JWT secret 未配置,请设置 JWT_SECRET 环境变量");
+        }
+        if (secret.length() < 32) {
+            throw new IllegalStateException("JWT secret 长度不能少于 32 位");
+        }
+        if (OLD_DEFAULT_SECRET.equals(secret)) {
+            throw new IllegalStateException("JWT secret 禁止使用默认密钥,请重新生成并设置 JWT_SECRET 环境变量");
         }
     }
 

+ 24 - 0
src/main/java/com/qqflow/engine/common/util/XmlUtil.java

@@ -0,0 +1,24 @@
+package com.qqflow.engine.common.util;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/17
+ */
+public final class XmlUtil {
+    private static final XmlMapper XML_MAPPER = new XmlMapper();
+
+    static {
+        XML_MAPPER.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+    }
+
+    public static <T> T fromXml(String xml, Class<T> clazz) {
+        try {
+            return XML_MAPPER.readValue(xml, clazz);
+        } catch (Exception e) {
+            throw new RuntimeException("XML 解析失败", e);
+        }
+    }
+
+}

+ 124 - 0
src/main/java/com/qqflow/engine/common/util/id/AesUtil.java

@@ -0,0 +1,124 @@
+package com.qqflow.engine.common.util.id;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.*;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * 跟mysql内置aes加解密方法一致
+ * <p>
+ * mysql中aes加解密函数使用示例
+ * 加密:select HEX(AES_ENCRYPT('138000138000', 'key'))
+ * 解密:select AES_DECRYPT(UNHEX('189443C6FB32431CEEAD702638A619BD'), 'key')
+ *
+ * 批量更新
+ *
+ * 批量从row_data字段加密到encrypt字段:
+ * UPDATE codec a set a.encrypt =HEX(AES_ENCRYPT(a.row_data, 'key'))
+ *
+ * 批量从encrypt字段解密到decrypt字段:U
+ * PDATE codec a set a.decrypt =AES_DECRYPT(UNHEX(a.encrypt), 'key')
+ *
+ * @author chenanlian
+ */
+
+public class AesUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(AesUtil.class);
+
+
+    /**
+     * 加密
+     *
+     * @param data 数据
+     * @param key  秘钥
+     * @return
+     */
+    public static String encrypt(String data, String key) {
+        try {
+            SecretKey secretKey = generateMySQLAESKey(key, "ASCII");
+            Cipher cipher = Cipher.getInstance("AES");
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
+            byte[] cleartext = data.getBytes("UTF-8");
+            byte[] ciphertextBytes = cipher.doFinal(cleartext);
+            return new String(Hex.encodeHex(ciphertextBytes));
+
+        } catch (UnsupportedEncodingException e) {
+            log.error(e.getMessage(), e);
+        } catch (NoSuchAlgorithmException e) {
+            log.error(e.getMessage(), e);
+        } catch (NoSuchPaddingException e) {
+            log.error(e.getMessage(), e);
+        } catch (InvalidKeyException e) {
+            log.error(e.getMessage(), e);
+        } catch (IllegalBlockSizeException e) {
+            log.error(e.getMessage(), e);
+        } catch (BadPaddingException e) {
+            log.error(e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 解密
+     *
+     * @param data
+     * @param key
+     * @return
+     */
+    public static String decrypt(String data, String key) {
+        try {
+            SecretKey secretKey = generateMySQLAESKey(key, "ASCII");
+            Cipher cipher = Cipher.getInstance("AES");
+            cipher.init(Cipher.DECRYPT_MODE, secretKey);
+            byte[] cleartext = Hex.decodeHex(data.toCharArray());
+            byte[] ciphertextBytes = cipher.doFinal(cleartext);
+            return new String(ciphertextBytes, "UTF-8");
+
+        } catch (UnsupportedEncodingException e) {
+            log.error(e.getMessage(), e);
+        } catch (NoSuchAlgorithmException e) {
+            log.error(e.getMessage(), e);
+        } catch (NoSuchPaddingException e) {
+            log.error(e.getMessage(), e);
+        } catch (InvalidKeyException e) {
+            log.error(e.getMessage(), e);
+        } catch (IllegalBlockSizeException e) {
+            log.error(e.getMessage(), e);
+        } catch (BadPaddingException e) {
+            log.error(e.getMessage(), e);
+        } catch (DecoderException e) {
+            log.error(e.getMessage(), e);
+        }
+        return null;
+    }
+
+
+    public static SecretKeySpec generateMySQLAESKey(final String key, final String encoding) throws UnsupportedEncodingException {
+
+        final byte[] finalKey = new byte[16];
+        int i = 0;
+        for (byte b : key.getBytes(encoding)) {
+            finalKey[i++ % 16] ^= b;
+        }
+        return new SecretKeySpec(finalKey, "AES");
+
+    }
+
+    public static void main(String[] args) {
+        String abc = "138000138000";
+        String aeskey = "ppmoney";
+        String a1 = encrypt(abc, aeskey);
+        System.out.println(a1.toUpperCase());
+        System.out.println(decrypt(a1, aeskey));
+    }
+
+
+}

+ 36 - 0
src/main/java/com/qqflow/engine/common/util/id/CodeUtil.java

@@ -0,0 +1,36 @@
+package com.qqflow.engine.common.util.id;
+
+
+import java.util.Random;
+
+public class CodeUtil {
+
+    public static String gen6() {
+        String[] keyItemArr = new String[]{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};
+        String key = "";
+        int keyLength = 6;
+        for (int i = 0; i < keyLength; i++) {
+            int random = new Random().nextInt(16);
+            String keyItem = keyItemArr[random];
+            key += keyItem;
+
+        }
+        return key;
+    }
+    public static String gen16() {
+        String[] keyItemArr = new String[]{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};
+        String key = "";
+        int keyLength = 16;
+        for (int i = 0; i < keyLength; i++) {
+            int random = new Random().nextInt(16);
+            String keyItem = keyItemArr[random];
+            key += keyItem;
+
+        }
+        return key;
+    }
+
+    public static String gen32() {
+        return IdUtil.genId() + gen16() + gen16();
+    }
+}

+ 140 - 0
src/main/java/com/qqflow/engine/common/util/id/GenIntegerUtil.java

@@ -0,0 +1,140 @@
+package com.qqflow.engine.common.util.id;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 长度在整数范围内的雪花算法
+ * 按10秒一个周期进行计数
+ * 总长度在10位以内,且不大于最大的Integer,2147483647
+ * 机器位,1位
+ * 序列位,1位
+ * 时间戳,8位
+ */
+public class GenIntegerUtil {
+
+    private static final int minNum = 0;
+    private static final AtomicInteger bigAtomic = new AtomicInteger(minNum);
+    private static volatile Integer machineCode = null;
+    private static AtomicLong recordSecond = null;
+    private static final long EPOCH = 168814080L;//20230701000000时刻的10秒时间戳
+    private static int maxTime = 21474835;
+
+    static {
+        checkAndInit();
+    }
+
+    //机器码1位或2位,
+
+    private static void checkAndInit() {
+        if (machineCode == null) {
+            synchronized (GenIntegerUtil.class) {
+                if (machineCode == null) {
+                    initData();
+                }
+            }
+        }
+    }
+
+    private static void initData() {
+        recordSecond = new AtomicLong(nowSecond());
+        try {
+            machineCode = 1;
+        } catch (Exception e) {
+            machineCode = 3;
+        }
+    }
+
+    /**
+     * 获取15位长度的自增长Key
+     * 每秒可生成8970个不重复编号
+     *
+     * @return long
+     */
+    public static int gen() {
+
+        //如果周期时间不是当前时间,重置周期时间,以及重置序列号
+        long nowSecond = nowSecond();
+        long time = recordSecond.get();
+        if (nowSecond > time) {
+            synchronized (GenIntegerUtil.class) {
+                nowSecond = nowSecond();
+                time = recordSecond.get();
+                if (nowSecond > time) {
+                    recordSecond = new AtomicLong(nowSecond);
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                }
+            }
+        }
+
+        int seq = bigAtomic.getAndIncrement();
+
+        //如果序列号达到最大,重置当前时间,并等待下一周期
+        if (seq > 99) {
+            synchronized (GenIntegerUtil.class) {
+                time = recordSecond.get();
+                seq = bigAtomic.getAndIncrement();
+                if (seq > 99) {
+                    recordSecond = new AtomicLong(nextSecond(recordSecond.get()));
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                    seq = bigAtomic.getAndIncrement();
+                }
+            }
+        }
+        time = time - EPOCH;
+        while (time > maxTime) {
+            time = time % maxTime;
+        }
+        String differ = String.valueOf(time);
+        return new Integer(differ + seq).intValue();
+    }
+
+    /**
+     * 获取下一不同秒的时间戳,不能与最后的时间戳一样
+     */
+    private static long nextSecond(long second) {
+        long now = nowSecond();
+        while (now <= second) {
+            try {
+                Thread.sleep(1000);
+            } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+            }
+            now = nowSecond();
+        }
+        return now;
+    }
+
+    /**
+     * 当前的10秒级时间戳
+     *
+     * @return
+     */
+    private static long nowSecond() {
+        return LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8")) / 10;
+    }
+
+    /**
+     * 秒级时间戳转id
+     *
+     * @param secondTimeStamp
+     * @return
+     */
+    public static int timeStamp2Id(Long secondTimeStamp) {
+        String differ = String.valueOf(secondTimeStamp/10 - EPOCH);
+        return new Integer(differ + minNum).intValue();
+    }
+
+    /**
+     * 秒级时间戳
+     * @param id
+     * @return
+     */
+    public static Long id2TimeStamp(int id) {
+        return (id / 10 + EPOCH)*10;
+    }
+}

+ 109 - 0
src/main/java/com/qqflow/engine/common/util/id/GenLong15ForTokenUtil.java

@@ -0,0 +1,109 @@
+package com.qqflow.engine.common.util.id;
+
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class GenLong15ForTokenUtil {
+
+    private static final int minNum = 1000;
+
+    private static final AtomicInteger bigAtomic = new AtomicInteger(minNum);
+
+    private static volatile Integer machineCode = null;
+
+    private static AtomicLong recordSecond = null;
+
+    private static final long EPOCH = 590000000L;
+
+    static {
+        checkAndInit();
+    }
+
+    private static void checkAndInit() {
+        if (machineCode == null) {
+            synchronized (GenLong15ForTokenUtil.class) {
+                if (machineCode == null) {
+                    initData();
+                }
+            }
+        }
+    }
+
+    private static void initData() {
+        recordSecond = new AtomicLong(nowSecond());
+        try {
+            String machineIp = InetAddress.getLocalHost().getHostAddress();
+            String codeStr = machineIp.substring(machineIp.length() - 1);
+            machineCode = new BigInteger(codeStr).intValue();
+        } catch (UnknownHostException e) {
+            machineCode = 3;
+        }
+    }
+
+    /**
+     * 获取15位长度的自增长Key
+     * 每秒可生成8970个不重复编号
+     *
+     * @return long
+     */
+    public static Long genLong15() {
+
+        //如果周期时间不是当前时间,重置周期时间,以及重置序列号
+        long nowSecond = nowSecond();
+        long time = recordSecond.get();
+        if (nowSecond > time) {
+            synchronized (GenLong15ForTokenUtil.class) {
+                nowSecond = nowSecond();
+                time = recordSecond.get();
+                if (nowSecond > time) {
+                    recordSecond = new AtomicLong(nowSecond);
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                }
+            }
+        }
+
+        int seq = bigAtomic.getAndIncrement();
+
+        //如果序列号达到最大,重置当前时间,并等待下一周期
+        if (seq >= 9999) {
+            synchronized (GenLong15ForTokenUtil.class) {
+                time = recordSecond.get();
+                seq = bigAtomic.getAndIncrement();
+                if (seq >= 9999) {
+                    recordSecond = new AtomicLong(nextSecond(recordSecond.get()));
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                    seq = bigAtomic.getAndIncrement();
+                }
+            }
+        }
+        String differ = String.valueOf(time - EPOCH);
+        return new BigInteger(differ + seq + machineCode).longValue();
+    }
+
+    /**
+     * 获取下一不同秒的时间戳,不能与最后的时间戳一样
+     */
+    private static long nextSecond(long second) {
+        long now = nowSecond();
+        while (now <= second) {
+            now = nowSecond();
+        }
+        return now;
+    }
+
+    /**
+     * 当前的秒级时间戳
+     *
+     * @return
+     */
+    private static long nowSecond() {
+        return LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
+    }
+}

+ 116 - 0
src/main/java/com/qqflow/engine/common/util/id/GenLong15Util.java

@@ -0,0 +1,116 @@
+package com.qqflow.engine.common.util.id;
+
+
+import java.math.BigInteger;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class GenLong15Util {
+
+    private static final int minNum = 1000;
+
+    private static final AtomicInteger bigAtomic = new AtomicInteger(minNum);
+
+    private static volatile Integer machineCode = 9;
+
+    private static AtomicLong recordSecond = null;
+
+    private static final long EPOCH = 590000000L;
+
+    static {
+        checkAndInit();
+    }
+
+    private static void checkAndInit() {
+        if (recordSecond == null) {
+            synchronized (GenLong15Util.class) {
+                if (recordSecond == null) {
+                    initData();
+                }
+            }
+        }
+    }
+
+    private static void initData() {
+        recordSecond = new AtomicLong(nowSecond());
+    }
+
+    /**
+     * 获取15位长度的自增长Key
+     * 每秒可生成8970个不重复编号
+     *
+     * @return long
+     */
+    public static Long genLong15() {
+
+        //如果周期时间不是当前时间,重置周期时间,以及重置序列号
+        long nowSecond = nowSecond();
+        long time = recordSecond.get();
+        if (nowSecond > time) {
+            synchronized (GenLong15Util.class) {
+                nowSecond = nowSecond();
+                time = recordSecond.get();
+                if (nowSecond > time) {
+                    recordSecond = new AtomicLong(nowSecond);
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                }
+            }
+        }
+
+        int seq = bigAtomic.getAndIncrement();
+
+        //如果序列号达到最大,重置当前时间,并等待下一周期
+        if (seq >= 9999) {
+            synchronized (GenLong15Util.class) {
+                time = recordSecond.get();
+                seq = bigAtomic.getAndIncrement();
+                if (seq >= 9999) {
+                    recordSecond = new AtomicLong(nextSecond(recordSecond.get()));
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                    seq = bigAtomic.getAndIncrement();
+                }
+            }
+        }
+        String differ = String.valueOf(time - EPOCH);
+        return new BigInteger(differ + seq + machineCode).longValue();
+    }
+
+    /**
+     * 获取下一不同秒的时间戳,不能与最后的时间戳一样
+     */
+    private static long nextSecond(long second) {
+        long now = nowSecond();
+        while (now <= second) {
+            now = nowSecond();
+        }
+        return now;
+    }
+
+    /**
+     * 当前的秒级时间戳
+     *
+     * @return
+     */
+    private static long nowSecond() {
+        return LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
+    }
+
+    /**
+     * 秒级时间戳转id
+     *
+     * @param secondTimeStamp
+     * @return
+     */
+    public static Long timeStamp2Id(Long secondTimeStamp) {
+        String differ = String.valueOf(secondTimeStamp - GenLong15Util.EPOCH);
+        return new BigInteger(differ + minNum + machineCode).longValue();
+    }
+
+    public static Long id2TimeStamp(Long id) {
+        return id / 100000 + GenLong15Util.EPOCH;
+    }
+}

+ 116 - 0
src/main/java/com/qqflow/engine/common/util/id/GenLong19Util.java

@@ -0,0 +1,116 @@
+package com.qqflow.engine.common.util.id;
+
+
+import java.math.BigInteger;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 生成19位不重复长整型
+ */
+public class GenLong19Util {
+
+
+    private static final int minNum = 10000;
+
+    private static final AtomicInteger bigAtomic = new AtomicInteger(minNum);
+
+    private static volatile Integer machineCode = 9;
+
+    private static AtomicLong recordSecond = null;
+
+    private static final long EPOCH = 590000000000L;
+
+    static {
+        checkAndInit();
+    }
+
+    private static void checkAndInit() {
+        if (machineCode == null) {
+            synchronized (GenLong19Util.class) {
+                if (machineCode == null) {
+                    initData();
+                }
+            }
+        }
+    }
+
+    private static void initData() {
+        recordSecond = new AtomicLong(nowSecond());
+    }
+
+    /**
+     * 获取15位长度的自增长Key
+     * 每秒可生成8970个不重复编号
+     *
+     * @return long
+     */
+    public static Long genLong19() {
+        return new BigInteger(genString19()).longValue();
+    }
+
+    public static String genString19() {
+        //如果周期时间不是当前时间,重置周期时间,以及重置序列号
+        long nowSecond = nowSecond();
+        long time = recordSecond.get();
+        if (nowSecond > time) {
+            synchronized (GenLong19Util.class) {
+                nowSecond = nowSecond();
+                time = recordSecond.get();
+                if (nowSecond > time) {
+                    recordSecond = new AtomicLong(nowSecond);
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                }
+            }
+        }
+
+        int seq = bigAtomic.getAndIncrement();
+
+        //如果序列号达到最大,重置当前时间,并等待下一周期
+        if (seq >= 99999) {
+            synchronized (GenLong19Util.class) {
+                time = recordSecond.get();
+                seq = bigAtomic.getAndIncrement();
+                if (seq >= 99999) {
+                    recordSecond = new AtomicLong(nextSecond(recordSecond.get()));
+                    time = recordSecond.get();
+                    bigAtomic.set(minNum);
+                    seq = bigAtomic.getAndIncrement();
+                }
+            }
+        }
+        String differ = String.valueOf(time - EPOCH);
+        return differ + seq + machineCode;
+    }
+
+    /**
+     * 获取下一不同秒的时间戳,不能与最后的时间戳一样
+     */
+    private static long nextSecond(long second) {
+        long now = nowSecond();
+        while (now <= second) {
+            now = nowSecond();
+        }
+        return now;
+    }
+
+    /**
+     * 当前的秒级时间戳
+     *
+     * @return
+     */
+    private static long nowSecond() {
+        return System.currentTimeMillis();
+    }
+
+
+    public static long timeNoEpoch(long sn) {
+        return sn + EPOCH * 1000000;
+    }
+
+    public static long id2TimeStamp(long id) {
+        Long timeStamp = id/1000000 + 590000000000L;
+        return timeStamp;
+    }
+}

+ 48 - 0
src/main/java/com/qqflow/engine/common/util/id/IdUtil.java

@@ -0,0 +1,48 @@
+package com.qqflow.engine.common.util.id;
+
+
+import java.util.Date;
+
+
+public class IdUtil {
+
+
+    /**
+     * 生成15位的长整型;超过15位js的解析有精度问题
+     *
+     * @return
+     */
+    public static long genId() {
+        return GenLong15Util.genLong15();
+    }
+
+    /**
+     * 日期转id
+     * @param date
+     * @return
+     */
+    public static Long date2Id(Date date) {
+        return GenLong15Util.timeStamp2Id(date.getTime()/1000);
+    }
+
+    /**
+     * 秒级时间戳转id
+     *
+     * @param secondTimeStamp
+     * @return
+     */
+    public static Long timeStamp2Id(Long secondTimeStamp) {
+        return GenLong15Util.timeStamp2Id(secondTimeStamp);
+    }
+
+    /**
+     * id转秒级时间戳
+     * @param id
+     * @return
+     */
+    public static Long id2TimeStamp(long id) {
+        return GenLong15Util.id2TimeStamp(id);
+    }
+
+
+}

+ 23 - 0
src/main/java/com/qqflow/engine/common/util/id/TokenUtil.java

@@ -0,0 +1,23 @@
+package com.qqflow.engine.common.util.id;
+
+
+
+import java.util.Date;
+
+public class TokenUtil {
+    private static String tokenKey = "Ninuo202009";
+
+    public static String genId() {
+        long id = GenLong19Util.genLong19();
+        String token = AesUtil.encrypt(String.valueOf(id), tokenKey);
+        //String token = MD5Util.getMD5(String.valueOf(id));
+        return token;
+    }
+
+
+    public static Long token2Id(String token) {
+        String token2 = AesUtil.decrypt(token, tokenKey);
+        return Long.valueOf(token2);
+    }
+
+}

+ 15 - 0
src/main/java/com/qqflow/engine/common/util/id/TraceidUtil.java

@@ -0,0 +1,15 @@
+package com.qqflow.engine.common.util.id;
+
+public class TraceidUtil {
+
+
+    /**
+     * 生成15位的长整型;超过15位js的解析有精度问题
+     * @return
+     */
+    public static String getId() {
+        return GenLong19Util.genString19();
+    }
+
+
+}

+ 5 - 28
src/main/java/com/qqflow/engine/config/EmbeddedRedisConfig.java

@@ -1,6 +1,5 @@
 package com.qqflow.engine.config;
 
-import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Configuration;
@@ -8,33 +7,19 @@ import org.springframework.context.annotation.Profile;
 import redis.embedded.RedisServer;
 
 import java.io.IOException;
-import java.net.Socket;
 
+/**
+ * Dev 环境下负责关闭 Embedded Redis。
+ * 启动逻辑已移至 {@link EmbeddedRedisInitializer},确保在 Redisson 等组件初始化前 Redis 已就绪。
+ */
 @Slf4j
 @Configuration
 @Profile("dev")
 public class EmbeddedRedisConfig {
 
-    private RedisServer redisServer;
-
-    @PostConstruct
-    public void startRedis() {
-        if (isPortAvailable(6379)) {
-            try {
-                log.info("本地 Redis 未检测到,启动 Embedded Redis (端口: 6379)...");
-                redisServer = new RedisServer(6379);
-                redisServer.start();
-                log.info("Embedded Redis 启动成功");
-            } catch (IOException e) {
-                log.error("Embedded Redis 启动失败", e);
-            }
-        } else {
-            log.info("检测到外部 Redis 正在运行 (端口: 6379),跳过 Embedded Redis 启动");
-        }
-    }
-
     @PreDestroy
     public void stopRedis() {
+        RedisServer redisServer = EmbeddedRedisInitializer.getRedisServer();
         if (redisServer != null && redisServer.isActive()) {
             try {
                 log.info("正在停止 Embedded Redis...");
@@ -44,12 +29,4 @@ public class EmbeddedRedisConfig {
             }
         }
     }
-
-    private boolean isPortAvailable(int port) {
-        try (Socket socket = new Socket("localhost", port)) {
-            return false;
-        } catch (IOException e) {
-            return true;
-        }
-    }
 }

+ 51 - 0
src/main/java/com/qqflow/engine/config/EmbeddedRedisInitializer.java

@@ -0,0 +1,51 @@
+package com.qqflow.engine.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.Profiles;
+import redis.embedded.RedisServer;
+
+import java.io.IOException;
+import java.net.Socket;
+
+/**
+ * 在 Spring Boot 启动最早期启动 Embedded Redis,确保 Redisson 等组件初始化时 Redis 已可用。
+ * 仅在 dev 环境下生效。
+ */
+@Slf4j
+public class EmbeddedRedisInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
+
+    private static RedisServer redisServer;
+
+    @Override
+    public void initialize(ConfigurableApplicationContext applicationContext) {
+        if (!applicationContext.getEnvironment().acceptsProfiles(Profiles.of("dev"))) {
+            return;
+        }
+        if (isPortAvailable(6379)) {
+            try {
+                log.info("本地 Redis 未检测到,启动 Embedded Redis (端口: 6379)...");
+                redisServer = new RedisServer(6379);
+                redisServer.start();
+                log.info("Embedded Redis 启动成功");
+            } catch (IOException e) {
+                log.error("Embedded Redis 启动失败", e);
+            }
+        } else {
+            log.info("检测到外部 Redis 正在运行 (端口: 6379),跳过 Embedded Redis 启动");
+        }
+    }
+
+    private boolean isPortAvailable(int port) {
+        try (Socket socket = new Socket("localhost", port)) {
+            return false;
+        } catch (IOException e) {
+            return true;
+        }
+    }
+
+    public static RedisServer getRedisServer() {
+        return redisServer;
+    }
+}

+ 31 - 0
src/main/java/com/qqflow/engine/config/OpenApiConfig.java

@@ -0,0 +1,31 @@
+package com.qqflow.engine.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+    @Bean
+    public OpenAPI openAPI() {
+        return new OpenAPI()
+                .info(new Info()
+                        .title("QQFlow Engine API")
+                        .description("通用型流程审批设计与管理系统后端接口")
+                        .version("1.0.0")
+                        .contact(new Contact().name("QQFlow Team")))
+                .addSecurityItem(new SecurityRequirement().addList("Bearer"))
+                .components(new Components()
+                        .addSecuritySchemes("Bearer", new SecurityScheme()
+                                .type(SecurityScheme.Type.HTTP)
+                                .scheme("bearer")
+                                .bearerFormat("JWT")
+                                .description("在 Value 输入框中填入 JWT Token")));
+    }
+}

+ 15 - 21
src/main/java/com/qqflow/engine/config/WebConfig.java

@@ -1,42 +1,36 @@
 package com.qqflow.engine.config;
 
-import org.springframework.beans.factory.annotation.Value;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
-import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
-import java.util.Arrays;
-import java.util.List;
+import java.time.format.DateTimeFormatter;
 
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
 
-    @Value("${cors.allowed-origins:http://localhost:3000,http://127.0.0.1:3000}")
-    private String allowedOrigins;
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
+        return builder -> builder
+                .serializers(new LocalDateTimeSerializer(DATE_TIME_FORMATTER))
+                .deserializers(new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));
+    }
 
     @Override
     public void addCorsMappings(CorsRegistry registry) {
-        List<String> origins = Arrays.stream(allowedOrigins.split(","))
-                .filter(StringUtils::hasText)
-                .map(String::trim)
-                .toList();
-        String[] originArray = origins.isEmpty()
-                ? new String[]{"http://localhost:3000", "http://127.0.0.1:3000"}
-                : origins.toArray(new String[0]);
-
         registry.addMapping("/**")
-                .allowedOriginPatterns(originArray)
+                .allowedOriginPatterns("*")
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)
                 .maxAge(3600);
     }
 
-    @Override
-    public void addResourceHandlers(ResourceHandlerRegistry registry) {
-        registry.addResourceHandler("/uploads/**")
-                .addResourceLocations("file:uploads/");
-    }
+    // 文件下载不再通过静态资源映射公开访问,统一走 FileDownloadController 做权限校验
 }

+ 12 - 3
src/main/java/com/qqflow/engine/config/security/LoginUser.java

@@ -6,6 +6,7 @@ import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.core.userdetails.UserDetails;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -29,9 +30,17 @@ public class LoginUser implements UserDetails {
     @Override
     @JsonIgnore
     public Collection<? extends GrantedAuthority> getAuthorities() {
-        return roles.stream()
-                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
-                .collect(Collectors.toList());
+        List<GrantedAuthority> authorities = new ArrayList<>();
+        if (roles != null) {
+            roles.stream()
+                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
+                    .forEach(authorities::add);
+        }
+        // employeeType 也作为角色,统一前端菜单和后端接口的权限判断
+        if (employeeType != null && !employeeType.isBlank()) {
+            authorities.add(new SimpleGrantedAuthority("ROLE_" + employeeType));
+        }
+        return authorities;
     }
 
     @Override

+ 2 - 1
src/main/java/com/qqflow/engine/config/security/SecurityConfig.java

@@ -30,9 +30,10 @@ public class SecurityConfig {
                 .authorizeHttpRequests(auth -> auth
                         .requestMatchers("/auth/**").permitAll()
                         .requestMatchers("/webhook/**").permitAll()
-                        .requestMatchers("/uploads/**").permitAll()
+                        .requestMatchers("/open-api/**").permitAll()
                         .requestMatchers(HttpMethod.OPTIONS).permitAll()
                         .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
+                        .requestMatchers("/WW_verify_*.txt").permitAll()
                         .anyRequest().authenticated()
                 )
                 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

+ 253 - 0
src/main/java/com/qqflow/engine/domain/ewechat/api/EnterpriseWechatApi.java

@@ -0,0 +1,253 @@
+package com.qqflow.engine.domain.ewechat.api;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.map.MapBuilder;
+import cn.hutool.core.map.MapUtil;
+import com.qqflow.engine.common.exception.BusinessException;
+import com.qqflow.engine.common.util.Assert;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.*;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/17
+ */
+@Component
+@RequiredArgsConstructor
+public class EnterpriseWechatApi {
+    private static final String BASE_URL = "https://qyapi.weixin.qq.com";
+
+    private final RestTemplate restTemplate;
+
+    private void checkRsp(EnterpriseWechatBaseRsp rsp){
+        Assert.notNull(rsp,"返回值为空");
+        boolean condition = Objects.equals(null, rsp.getErrcode()) || Objects.equals(rsp.getErrcode(), 0);
+        Assert.isTure(condition, rsp.getErrmsg());
+    }
+
+    /**
+     * 获取企业微信后台推送的ticket
+     * suiteId 第三方应用id或者代开发应用模板id。第三方应用以ww或wx开头应用id(对应于旧的以tj开头的套件id);代开发应用以dk开头
+     * @param suiteTicket suite_ticket由企业微信后台定时推送给“指令回调URL”,每十分钟更新一次,见推送suite_ticket。
+     * @return 第三方应用凭证(用于获取永久授权码)
+     */
+    public EnterpriseWechatSuiteAccessTokenRsp getSuiteAccessToken(String suiteId, String suiteSecret, String suiteTicket) {
+        EnterpriseWechatSuiteAccessTokenReq req = EnterpriseWechatSuiteAccessTokenReq.of(suiteId, suiteSecret, suiteTicket);
+        return this.commonPost("/cgi-bin/service/get_suite_token", req, EnterpriseWechatSuiteAccessTokenRsp.class);
+    }
+
+    /** 获取授权信息 */
+    public EnterpriseWechatPermanentCodeRsp getPermanentCode(String suiteAccessToken, String authCode){
+        Map<String, Object> req = MapBuilder.<String,Object>create().put("auth_code", authCode).build();
+        String uri = "/cgi-bin/service/get_permanent_code?suite_access_token=" + suiteAccessToken;
+        return this.commonPost(uri, req, EnterpriseWechatPermanentCodeRsp.class);
+    }
+
+    /** 获取accesstoken,基本上要调代开发的接口都需要用到这个 */
+    public EnterpriseWechatAccessTokenRsp getAccessToken(String corpId, String permanentCode) {
+        String uri = "/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + permanentCode;
+        return this.commonGet(uri, EnterpriseWechatAccessTokenRsp.class);
+    }
+
+    /** 获取成员ID列表(open_userid + 部门ID),支持分页游标 */
+    public EnterpriseWechatListIdRsp listUserId(String accessToken, String cursor, Integer limit) {
+        EnterpriseWechatListIdReq req = new EnterpriseWechatListIdReq();
+        req.setCursor(cursor);
+        req.setLimit(limit);
+        String uri = "/cgi-bin/user/list_id?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatListIdRsp.class);
+    }
+
+    /** 获取部门列表(递归获取指定部门及其子部门,不填id则获取全量) */
+    public EnterpriseWechatDepartmentListRsp listDepartment(String accessToken, Long deptId) {
+        String uri = "/cgi-bin/department/list?access_token=" + accessToken;
+        if (deptId != null) uri += "&id=" + deptId;
+        return this.commonGet(uri, EnterpriseWechatDepartmentListRsp.class);
+    }
+
+    /** 获取部门成员(仅返回本部门直属成员,不含子部门) */
+    public EnterpriseWechatUserListRsp listDepartmentUsers(String accessToken, Long departmentId) {
+        String uri = "/cgi-bin/user/simplelist?access_token=" + accessToken + "&department_id=" + departmentId;
+        return this.commonGet(uri, EnterpriseWechatUserListRsp.class);
+    }
+
+    /** 通过手机号获取userid */
+    public EnterpriseWechatUseridByMobileRsp getUseridByMobile(String accessToken, String mobile) {
+        EnterpriseWechatUseridByMobileReq req = EnterpriseWechatUseridByMobileReq.of(mobile);
+        String uri = "/cgi-bin/user/getuserid?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatUseridByMobileRsp.class);
+    }
+
+    /**
+     * 添加客服
+     * @param name    客服名称
+     * @param mediaId 素材ID
+     */
+    public EnterpriseWechatAddCsRsp addCs(String accessToken, String name, String mediaId) {
+        EnterpriseWechatAddCsReq req = EnterpriseWechatAddCsReq.of(name, mediaId);
+        String uri = "/cgi-bin/kf/account/add?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatAddCsRsp.class);
+    }
+
+    public EnterpriseWechatCsUrlRsp getCsUrl(String accessToken, String csId) {
+        EnterpriseWechatCsUrlReq req = EnterpriseWechatCsUrlReq.of(csId, "test1");
+        String uri = "/cgi-bin/kf/add_contact_way?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatCsUrlRsp.class);
+    }
+
+    public EnterpriseWechatBaseRsp deleteCs(String accessToken, String openKfid) {
+        String uri = "/cgi-bin/kf/account/del?access_token=" + accessToken;
+        Map<String, String> req = MapUtil.builder("open_kfid", openKfid).build();
+        return this.commonPost(uri, req, EnterpriseWechatBaseRsp.class);
+    }
+
+    /** 同步消息 */
+    public EnterpriseWechatSyncMsgRsp syncMsg(String accessToken, String cursor, String token, String openKfId) {
+        EnterpriseWechatSyncMsgReq req = EnterpriseWechatSyncMsgReq.of(cursor, token, openKfId);
+        String uri = "/cgi-bin/kf/sync_msg?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSyncMsgRsp.class);
+    }
+
+    /** 获取会话状态 */
+    public EnterpriseWechatSessionRsp getSessionState(String accessToken, String openKfId, String userId){
+        EnterpriseWechatSessionReq req = EnterpriseWechatSessionReq.of(openKfId, userId);
+        String uri = "/cgi-bin/kf/service_state/get?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSessionRsp.class);
+    }
+
+    /** 变更会话状态 */
+    public EnterpriseWechatChangeSessionRsp changeSessionState(String accessToken, String openKfId, String userId, Integer serviceState){
+        EnterpriseWechatChangeSessionReq req = EnterpriseWechatChangeSessionReq.of(openKfId, userId, serviceState);
+        String uri = "/cgi-bin/kf/service_state/trans?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatChangeSessionRsp.class);
+    }
+
+    /** 发送回应消息 */
+    public EnterpriseWechatSendMsgRsp sendMsgOnEvent(String accessToken, String code, String content){
+        EnterpriseWechatSendOnEventMsgReq req = EnterpriseWechatSendOnEventMsgReq.of(code, content);
+        String uri = "/cgi-bin/kf/send_msg_on_event?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSendMsgRsp.class);
+    }
+
+    /** 发送客服消息 */
+    public EnterpriseWechatSendMsgRsp sendKfMsg(String accessToken, EnterpriseWechatSendMsgReq req){
+        String uri = "/cgi-bin/kf/send_msg?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSendMsgRsp.class);
+    }
+
+    /** 发送应用文本消息 */
+    public EnterpriseWechatSendTextMsgRsp sendTextMsg(String accessToken, EnterpriseWechatSendTextMsgReq req) {
+        String uri = "/cgi-bin/message/send?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSendTextMsgRsp.class);
+    }
+
+    /** 上传素材文件 */
+    public EnterpriseWechatUploadRsp upload(String accessToken, String type, File file) {
+        return this.upload(accessToken, type, getResource(file.getPath()), (int) file.length());
+    }
+
+    public EnterpriseWechatUploadRsp upload(String accessToken, String type, MultipartFile multipartFile) {
+        return this.upload(accessToken, type, multipartFile.getResource(), (int) multipartFile.getSize());
+    }
+
+    private EnterpriseWechatUploadRsp upload(String accessToken, String type, Resource resource, int fileLength) {
+        String url = BASE_URL + "/cgi-bin/media/upload?access_token=" + accessToken + "&type=" + type;
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        body.add("file", resource);
+        HttpHeaders headers = new HttpHeaders();
+        headers.set("Content-disposition", "form-data; name=media;filename=" + resource.getFilename() + "; filelength=" + fileLength);
+        headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+
+        HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
+        ResponseEntity<EnterpriseWechatUploadRsp> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, EnterpriseWechatUploadRsp.class);
+        this.checkRsp(response.getBody());
+        return response.getBody();
+    }
+
+    private static Resource getResource(String path) {
+        // 先尝试从 classpath 加载
+        Resource resource = new ClassPathResource(path);
+        if (resource.exists()) {
+            return resource;
+        }
+
+        // 如果不存在,再尝试从文件系统加载
+        File file = new File(path);
+        if (file.exists()) {
+            return new FileSystemResource(file);
+        }
+
+        throw new IllegalArgumentException("File not found: " + path);
+    }
+
+    /** 下载临时素材 */
+    public String downloadMedia(String accessToken, String mediaId) {
+        String url = BASE_URL + "/cgi-bin/media/get?access_token=" + accessToken + "&media_id=" + mediaId;
+        HttpHeaders headers = new HttpHeaders();
+        headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0");
+
+        RequestEntity<Void> request = new RequestEntity<>(headers, HttpMethod.GET, URI.create(url));
+        ResponseEntity<byte[]> response = restTemplate.exchange(request, byte[].class);
+        Assert.isTure(response.getStatusCode() == HttpStatus.OK, "download media failed");
+        // 获取 Content-Disposition 头
+        HttpHeaders responseHeaders = response.getHeaders();
+        String contentType = responseHeaders.getContentType() != null ? responseHeaders.getContentType().toString() : "";
+        if (contentType.contains("application/json")) {
+            throw new BusinessException("download media failed,error:" + new String(response.getBody()));
+        }
+        String fileName = extractFileName(responseHeaders).orElseThrow(() -> new BusinessException("can not get file name"));
+        // 保存文件
+        FileUtil.writeBytes(response.getBody(),fileName);
+        return fileName;
+    }
+
+    private static Optional<String> extractFileName(HttpHeaders headers) {
+        List<String> dispositions = headers.get(HttpHeaders.CONTENT_DISPOSITION);
+        if (dispositions != null) {
+            for (String disposition : dispositions) {
+                if (disposition.contains("filename=")) {
+                    return Optional.of(disposition.split("filename=")[1].replaceAll("\"", ""));
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    private <T extends EnterpriseWechatBaseRsp> T commonPost(String uri, Object req, Class<T> rspClass) {
+        String url = BASE_URL + uri;
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        HttpEntity<Object> requestEntity = new HttpEntity<>(req, headers);
+        T rsp = restTemplate.postForObject(url, requestEntity, rspClass);
+        this.checkRsp(rsp);
+        return rsp;
+    }
+
+    private <T extends EnterpriseWechatBaseRsp> T commonGet(String uri, Class<T> rspClass) {
+        String url = BASE_URL + uri;
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        HttpEntity<Object> requestEntity = new HttpEntity<>(headers);
+        ResponseEntity<T> exchange = restTemplate.exchange(url, HttpMethod.GET, requestEntity, rspClass);
+        this.checkRsp(exchange.getBody());
+        return exchange.getBody();
+    }
+
+}

+ 38 - 0
src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatAssembler.java

@@ -0,0 +1,38 @@
+package com.qqflow.engine.domain.ewechat.assemble;
+
+import com.qqflow.engine.common.assemble.CommonAssembler;
+import com.qqflow.engine.domain.ewechat.config.EnterpriseWechatProperties;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatPermanentCodeRsp;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Component
+@RequiredArgsConstructor
+public class EnterpriseWechatAssembler extends CommonAssembler {
+    private final EnterpriseWechatProperties enterpriseWechatProperties;
+
+    public EnterpriseWechatCorp assembleCorp(EnterpriseWechatPermanentCodeRsp rsp){
+        EnterpriseWechatCorp corp = assembleCommon(EnterpriseWechatCorp.class);
+        EnterpriseWechatPermanentCodeRsp.AuthCorpInfo authCorpInfo = rsp.getAuthCorpInfo();
+        EnterpriseWechatPermanentCodeRsp.AuthUserInfo authUserInfo = rsp.getAuthUserInfo();
+        EnterpriseWechatPermanentCodeRsp.RegisterCodeInfo registerCodeInfo = rsp.getRegisterCodeInfo();
+        corp.setPermanentCode(rsp.getPermanentCode());
+        corp.setCorpId(authCorpInfo.getCorpid());
+        corp.setSuiteId(enterpriseWechatProperties.getSuiteId());
+        corp.setCorpName(authCorpInfo.getCorpName());
+        corp.setAuthUserId(authUserInfo.getUserid());
+        corp.setAuthOpenUserId(authUserInfo.getOpenUserid());
+        if (Objects.nonNull(registerCodeInfo)) {
+            corp.setRegisterCode(registerCodeInfo.getRegisterCode());
+            corp.setTemplateId(registerCodeInfo.getTemplateId());
+        }
+        return corp;
+    }
+}

+ 71 - 0
src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatSessionAssembler.java

@@ -0,0 +1,71 @@
+package com.qqflow.engine.domain.ewechat.assemble;
+
+import com.qqflow.engine.common.assemble.CommonAssembler;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatMsgType;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendMsgReq;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg.EnterpriseWechatImageMsg;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg.EnterpriseWechatMsg;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg.EnterpriseWechatTextMsg;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSession;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSessionMsg;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Component
+public class EnterpriseWechatSessionAssembler extends CommonAssembler {
+    public EnterpriseWechatSession assembleSession(String openKfid, String externalUserid, String corpId) {
+        EnterpriseWechatSession session = this.assembleCommon(EnterpriseWechatSession.class);
+        session.setExternalUserid(externalUserid);
+        session.setOpenKfid(openKfid);
+        session.setCorpId(corpId);
+        return session;
+    }
+
+    public EnterpriseWechatSessionMsg assembleWelcomeMsg(String msgid, String value, String welcomeCode,Long sessionId) {
+        EnterpriseWechatSessionMsg sessionMsg = this.assembleCommon(EnterpriseWechatSessionMsg.class);
+        sessionMsg.setMsgId(msgid);
+        sessionMsg.setCode(welcomeCode);
+        sessionMsg.setValue(value);
+        sessionMsg.setOrigin(5);
+        sessionMsg.setMsgType(EnterpriseWechatMsgType.text);
+        sessionMsg.setSessionId(sessionId);
+        return sessionMsg;
+    }
+
+    public EnterpriseWechatSessionMsg assembleMsg(EnterpriseWechatSendMsgReq req, String msgid, Long sessionId) {
+        EnterpriseWechatSessionMsg sessionMsg = this.assembleCommon(EnterpriseWechatSessionMsg.class);
+        sessionMsg.setMsgId(msgid);
+        sessionMsg.setOrigin(5);
+        sessionMsg.setMsgType(req.getMsgtype());
+        sessionMsg.setSessionId(sessionId);
+        switch (req.getMsgtype()){
+            case text:
+                sessionMsg.setValue(req.getMsgValue());
+                break;
+            case image:
+                sessionMsg.setMediaId(req.getMsgValue());
+        }
+        return sessionMsg;
+    }
+
+    public EnterpriseWechatSessionMsg assembleMsg(EnterpriseWechatMsg msg, Long sessionId){
+        EnterpriseWechatSessionMsg sessionMsg = this.assembleCommon(EnterpriseWechatSessionMsg.class);
+        sessionMsg.setMsgId(msg.getMsgid());
+        sessionMsg.setOrigin(msg.getOrigin());
+        sessionMsg.setMsgType(msg.getMsgtype());
+        sessionMsg.setSessionId(sessionId);
+        switch (msg.getMsgtype()){
+            case text:
+                EnterpriseWechatTextMsg textMsg = ((EnterpriseWechatTextMsg) msg);
+                sessionMsg.setValue(textMsg.getText().getContent());
+                break;
+            case image:
+                EnterpriseWechatImageMsg imageMsg= ((EnterpriseWechatImageMsg) msg);
+                sessionMsg.setMediaId(imageMsg.getImage().getMediaId());
+        }
+        return sessionMsg;
+    }
+}

+ 111 - 0
src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatEventHandler.java

@@ -0,0 +1,111 @@
+package com.qqflow.engine.domain.ewechat.component;
+
+import com.qqflow.engine.common.service.RedissonService;
+import com.qqflow.engine.common.util.Assert;
+import com.qqflow.engine.common.util.RedisCache;
+import com.qqflow.engine.domain.ewechat.api.EnterpriseWechatApi;
+import com.qqflow.engine.domain.ewechat.assemble.EnterpriseWechatAssembler;
+import com.qqflow.engine.domain.ewechat.config.EnterpriseWechatProperties;
+import com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatCorpMapper;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatInfoType;
+import com.qqflow.engine.domain.ewechat.model.enterprise.EnterpriseWechatEvent;
+import com.qqflow.engine.domain.ewechat.model.enterprise.callback.EnterpriseWechatAuthNotice;
+import com.qqflow.engine.domain.ewechat.model.enterprise.callback.EnterpriseWechatSuiteTicket;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatPermanentCodeRsp;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatSuiteAccessTokenRsp;
+import com.qqflow.engine.common.util.XmlUtil;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author WuWenYi
+ * @see EnterpriseWechatInfoType
+ * @since 2025/3/18
+ */
+@Component
+@RequiredArgsConstructor
+public class EnterpriseWechatEventHandler {
+    private static final String SUITE_TICKET_KEY_PREFIX = "suite_ticket::";
+
+    private static final String SUITE_ACCESS_TOKEN_KEY_PREFIX = "suite_access_token::";
+
+    private final EnterpriseWechatCorpMapper corpMapper;
+    private final EnterpriseWechatAssembler assembler;
+    private final EnterpriseWechatProperties wechatProperties;
+
+    private final RedisCache redisCache;
+    private final RedissonService redissonService;
+
+    private final EnterpriseWechatApi enterpriseWechatApi;
+
+    /** 授权事件 */
+    @EventListener(value = {EnterpriseWechatEvent.class}, condition = "#event.source.toString()=='create_auth'")
+    public void createAuth(EnterpriseWechatEvent event) {
+        EnterpriseWechatAuthNotice authNotice = XmlUtil.fromXml(event.getCallbackXml(), EnterpriseWechatAuthNotice.class);
+        boolean locked = redissonService.tryLock("hanle-create-auth-event::" + authNotice.getSuiteId());
+        if (!locked) return;
+        try {
+            EnterpriseWechatPermanentCodeRsp permanentCodeRsp = this.fetchPermanentCode(authNotice);
+            String corpid = permanentCodeRsp.getAuthCorpInfo().getCorpid();
+            EnterpriseWechatCorp wechatCorp = corpMapper.selectByCorpIdAndSuiteId(corpid, authNotice.getSuiteId());
+            if (wechatCorp == null) {
+                corpMapper.insert(assembler.assembleCorp(permanentCodeRsp));
+            } else {
+                wechatCorp.setPermanentCode(permanentCodeRsp.getPermanentCode());
+                corpMapper.updateById(wechatCorp);
+            }
+        } finally {
+            redissonService.unlock("hanle-create-auth-event::" + authNotice.getSuiteId());
+        }
+    }
+
+    /** 取消授权事件 */
+    @EventListener(value = {EnterpriseWechatEvent.class}, condition = "#event.source.toString()=='cancel_auth'")
+    public void cancelAuth(EnterpriseWechatEvent event) {
+        // TODO: 取消授权事件处理
+    }
+
+    /** 推送suite_ticket事件 */
+    @EventListener(value = {EnterpriseWechatEvent.class}, condition = "#event.source.toString()=='suite_ticket'")
+    public void suiteTicket(EnterpriseWechatEvent event) {
+        EnterpriseWechatSuiteTicket suiteTicket = XmlUtil.fromXml(event.getCallbackXml(), EnterpriseWechatSuiteTicket.class);
+        String key = SUITE_TICKET_KEY_PREFIX + suiteTicket.getSuiteId();
+        redisCache.setCacheObject(key, suiteTicket.getSuiteTicket(), 60 * 30, TimeUnit.SECONDS);
+    }
+
+    /** 永久授权码更新事件(企业重新授权时触发) */
+    @EventListener(value = {EnterpriseWechatEvent.class}, condition = "#event.source.toString()=='reset_permanent_code'")
+    public void resetPermanentCode(EnterpriseWechatEvent event) {
+        EnterpriseWechatAuthNotice authNotice = XmlUtil.fromXml(event.getCallbackXml(), EnterpriseWechatAuthNotice.class);
+        boolean locked = redissonService.tryLock("handle-reset-permanent-code::" + authNotice.getSuiteId());
+        if (!locked) return;
+        try {
+            EnterpriseWechatPermanentCodeRsp permanentCodeRsp = this.fetchPermanentCode(authNotice);
+            String corpid = permanentCodeRsp.getAuthCorpInfo().getCorpid();
+            EnterpriseWechatCorp wechatCorp = corpMapper.selectByCorpIdAndSuiteId(corpid, authNotice.getSuiteId());
+            if (wechatCorp != null) {
+                wechatCorp.setPermanentCode(permanentCodeRsp.getPermanentCode());
+                wechatCorp.setCorpName(permanentCodeRsp.getAuthCorpInfo().getCorpName());
+                corpMapper.updateById(wechatCorp);
+            }
+        } finally {
+            redissonService.unlock("handle-reset-permanent-code::" + authNotice.getSuiteId());
+        }
+    }
+
+    /** 获取永久授权码:suite_ticket → suite_access_token → permanent_code */
+    private EnterpriseWechatPermanentCodeRsp fetchPermanentCode(EnterpriseWechatAuthNotice authNotice) {
+        String suiteTicket = redisCache.getCacheObject(SUITE_TICKET_KEY_PREFIX + authNotice.getSuiteId());
+        Assert.notBlank(suiteTicket, "suite_ticket不存在");
+        EnterpriseWechatSuiteAccessTokenRsp tokenRsp = enterpriseWechatApi.getSuiteAccessToken(
+                wechatProperties.getSuiteId(), wechatProperties.getSuiteSecret(), suiteTicket);
+        redisCache.setCacheObject(SUITE_ACCESS_TOKEN_KEY_PREFIX + authNotice.getSuiteId(),
+                tokenRsp.getSuiteAccessToken(), tokenRsp.getExpiresIn(), TimeUnit.SECONDS);
+        return enterpriseWechatApi.getPermanentCode(tokenRsp.getSuiteAccessToken(), authNotice.getAuthCode());
+    }
+
+}

+ 69 - 0
src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatValidator.java

@@ -0,0 +1,69 @@
+package com.qqflow.engine.domain.ewechat.component;
+
+import com.qqflow.engine.domain.ewechat.config.EnterpriseWechatProperties;
+import com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatCorpMapper;
+import com.qqflow.engine.domain.ewechat.model.enterprise.callback.EnterpriseWechatCallbackReq;
+import com.qqflow.engine.domain.ewechat.model.enterprise.callback.EnterpriseWechatCallbackXmlReq;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
+import com.qqflow.engine.domain.ewechat.util.aes.AesException;
+import com.qqflow.engine.domain.ewechat.util.aes.WXBizJsonMsgCrypt;
+import com.qqflow.engine.common.util.XmlUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/14
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class EnterpriseWechatValidator {
+    private final EnterpriseWechatProperties properties;
+
+    private final EnterpriseWechatCorpMapper corpMapper;
+
+    public String verifyGetReq(EnterpriseWechatCallbackReq req) {
+        return this.commonVerifyGetReq(req, properties.getSCorpId());
+    }
+
+    public String decrypt(EnterpriseWechatCallbackReq req, String xmlReqStr) {
+        try {
+            EnterpriseWechatCallbackXmlReq xmlReq = XmlUtil.fromXml(xmlReqStr, EnterpriseWechatCallbackXmlReq.class);
+            WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(properties.getToken(), properties.getEncodingAESKey(), xmlReq.getToUserName());
+            String decryptMsg = wxcpt.DecryptMsg(req.getMsg_signature(), String.valueOf(req.getTimestamp()), req.getNonce(), xmlReq.getMsgEncrypt());
+            log.info("企微解密处理结果: {}", decryptMsg);
+            return decryptMsg;
+        } catch (AesException e) {
+            log.error("encrypt error", e);
+            return null;
+        }
+    }
+
+    public String appVerifyGetReq(EnterpriseWechatCallbackReq req) {
+        List<EnterpriseWechatCorp> corps = corpMapper.selectAllCorps();
+        for (EnterpriseWechatCorp corp : corps) {
+            String result = this.commonVerifyGetReq(req, corp.getCorpId());
+            if (Objects.nonNull(result)) {
+                return result;
+            }
+        }
+        return null;
+    }
+
+    public String commonVerifyGetReq(EnterpriseWechatCallbackReq req, String corpId) {
+        try {
+            WXBizJsonMsgCrypt wxcpt = new WXBizJsonMsgCrypt(properties.getToken(), properties.getEncodingAESKey(), corpId);
+            String result = wxcpt.VerifyURL(req.getMsg_signature(), String.valueOf(req.getTimestamp()), req.getNonce(), req.getEchostr());
+            log.info("verifyGetReq result: {}", result);
+            return result;
+        } catch (AesException e) {
+            log.info("verifyGetReq error:{}", e.getMessage());
+            return null;
+        }
+    }
+}

+ 18 - 0
src/main/java/com/qqflow/engine/domain/ewechat/config/EnterpriseWechatConfig.java

@@ -0,0 +1,18 @@
+package com.qqflow.engine.domain.ewechat.config;
+
+import com.qqflow.engine.domain.ewechat.util.aes.AesException;
+import com.qqflow.engine.domain.ewechat.util.aes.WXBizJsonMsgCrypt;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Configuration
+public class EnterpriseWechatConfig {
+    @Bean
+    public WXBizJsonMsgCrypt encryptor(EnterpriseWechatProperties properties) throws AesException {
+        return new WXBizJsonMsgCrypt(properties.getToken(), properties.getEncodingAESKey(), properties.getSCorpId());
+    }
+}

+ 33 - 0
src/main/java/com/qqflow/engine/domain/ewechat/config/EnterpriseWechatProperties.java

@@ -0,0 +1,33 @@
+package com.qqflow.engine.domain.ewechat.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/14
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "enterprise-wechat")
+public class EnterpriseWechatProperties {
+    /** 企业id */
+    private String sCorpId;
+
+    /** token跟encodingAESKey,约定全部的代开发模板和代开发应用都用这个 */
+    private String token;
+
+    private String encodingAESKey;
+
+    /** 代开发应用模板id */
+    private String suiteId;
+
+    /** 代开发应用模板secret */
+    private String suiteSecret;
+
+    /** 授权方应用id */
+    private Integer agentId;
+}

+ 96 - 0
src/main/java/com/qqflow/engine/domain/ewechat/controller/EnterpriseTestController.java

@@ -0,0 +1,96 @@
+package com.qqflow.engine.domain.ewechat.controller;
+
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.domain.ewechat.api.EnterpriseWechatApi;
+import com.qqflow.engine.domain.ewechat.dto.EnterpriseCorpDto;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendMsgReq;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.*;
+import com.qqflow.engine.domain.ewechat.service.EnterpriseWechatService;
+import lombok.RequiredArgsConstructor;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/25
+ */
+@Tag(name = "内部企微测试用接口")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/enterprise/test/p")
+public class EnterpriseTestController {
+    private final EnterpriseWechatApi enterpriseWechatApi;
+    private final EnterpriseWechatService enterpriseWechatService;
+
+    @GetMapping("/change-session-state")
+    public Result<EnterpriseWechatChangeSessionRsp> changeSessionState(@RequestParam String userId, @RequestParam String openKfId,
+            @RequestParam Integer sessionState, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatChangeSessionRsp rsp = enterpriseWechatApi.changeSessionState(accessToken, openKfId, userId, sessionState);
+        return Result.ok(rsp);
+    }
+
+    @GetMapping("/get-session-state")
+    public Result<EnterpriseWechatSessionRsp> getSessionState(@RequestParam String userId, @RequestParam String openKfId, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatSessionRsp sessionState = enterpriseWechatApi.getSessionState(accessToken, openKfId, userId);
+        return Result.ok(sessionState);
+    }
+
+    @GetMapping("/get-media")
+    public Result<String> getMedia(@RequestParam String mediaId, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        String media = enterpriseWechatApi.downloadMedia(accessToken, mediaId);
+        File file = new File(media);
+        file.deleteOnExit();
+        return Result.ok(media);
+    }
+
+//    @PostMapping("/upload-media")
+//    public Result<EnterpriseWechatUploadRsp> uploadMedia(@RequestParam String mediaType, @RequestParam String imgUrl, EnterpriseCorpDto dto) {
+//        File file = HttpClientUtil.downloadFile(imgUrl);
+//        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+//        EnterpriseWechatUploadRsp uploadRsp = enterpriseWechatApi.upload(accessToken, mediaType, file);
+//        file.deleteOnExit();
+//        return Result.ok(uploadRsp);
+//    }
+
+    @PostMapping("/upload-media2")
+    public Result<EnterpriseWechatUploadRsp> uploadMedia(@RequestParam String mediaType, @RequestParam MultipartFile file, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatUploadRsp uploadRsp = enterpriseWechatApi.upload(accessToken, mediaType, file);
+        return Result.ok(uploadRsp);
+    }
+
+    @PostMapping("/send-img-msg")
+    public Result<EnterpriseWechatSendMsgRsp> sendImg(@RequestBody EnterpriseWechatSendMsgReq req, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatSendMsgRsp sendMsgRsp = enterpriseWechatApi.sendKfMsg(accessToken, req);
+        return Result.ok(sendMsgRsp);
+    }
+
+    @PostMapping("/add-cs")
+    public Result<EnterpriseWechatAddCsRsp> addCs(@RequestParam String name, @RequestParam String mediaId, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatAddCsRsp rsp = enterpriseWechatApi.addCs(accessToken, name, mediaId);
+        return Result.ok(rsp);
+    }
+
+    @PostMapping("get-cs-url")
+    public Result<EnterpriseWechatCsUrlRsp> getCsUrl(@RequestParam String csId, EnterpriseCorpDto dto) {
+        //wkU17nDAAATaRZs9Xtf1urye0rOws6Rw
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatCsUrlRsp rsp = enterpriseWechatApi.getCsUrl(accessToken, csId);
+        return Result.ok(rsp);
+    }
+
+    @PostMapping("delete-cs")
+    public Result<EnterpriseWechatBaseRsp> deleteCs(@RequestParam String openKfid, EnterpriseCorpDto dto) {
+        String accessToken = enterpriseWechatService.getAccessToken(dto.getCorpId(), dto.getSuiteId());
+        EnterpriseWechatBaseRsp rsp = enterpriseWechatApi.deleteCs(accessToken, openKfid);
+        return Result.ok(rsp);
+    }
+}

+ 58 - 0
src/main/java/com/qqflow/engine/domain/ewechat/controller/EnterpriseWechatCallbackController.java

@@ -0,0 +1,58 @@
+package com.qqflow.engine.domain.ewechat.controller;
+
+import com.qqflow.engine.common.util.JsonUtil;
+import com.qqflow.engine.domain.ewechat.component.EnterpriseWechatValidator;
+import com.qqflow.engine.domain.ewechat.model.enterprise.callback.EnterpriseWechatCallbackReq;
+import com.qqflow.engine.domain.ewechat.service.EnterpriseWechatCallbackService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/14
+ */
+@Slf4j
+@Tag(name = "企微回调接口")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/open-api/enterprise-wechat/")
+public class EnterpriseWechatCallbackController {
+    private final EnterpriseWechatValidator enterpriseWechatValidator;
+    private final EnterpriseWechatCallbackService enterpriseWechatCallbackService;
+
+    @Operation(summary = "企微回调")
+    @GetMapping("/callback")
+    public String enterpriseWechatCallback(EnterpriseWechatCallbackReq req) {
+        log.info("企微回调接口收到请求:{}", JsonUtil.entity2Json(req));
+        return enterpriseWechatValidator.verifyGetReq(req);
+    }
+
+    @Operation(summary = "企微post回调")
+    @PostMapping(value = "/callback")
+    public String enterpriseWechatPostCallback(EnterpriseWechatCallbackReq req, @RequestBody String reqBody) {
+        log.info("企微post回调接口收到请求:{}", JsonUtil.entity2Json(req));
+        log.info("企微post回调接口收到请求:{}", reqBody);
+        enterpriseWechatCallbackService.callback(req, reqBody);
+        return "success";
+    }
+
+    @Operation(summary = "企微应用验证回调")
+    @GetMapping("/app-callback")
+    public String wechatCallback(EnterpriseWechatCallbackReq req) {
+        log.info("get回调接口收到请求:{}", JsonUtil.entity2Json(req));
+        return enterpriseWechatValidator.appVerifyGetReq(req);
+    }
+
+//    @Operation(summary = "企微应用回调")
+//    @PostMapping("/{callbackCode}/callback")
+//    public String wechatCallback(EnterpriseWechatCallbackReq req, @PathVariable String callbackCode, @RequestBody String reqBody) {
+//        log.info("企微应用post回调接口收到请求,callbackCode:{}, body::{}", callbackCode, JsonUtil.entity2Json(req));
+//        log.info("企微应用post回调接口收到请求:callbackCode:{}, body::{}", callbackCode, reqBody);
+//        enterpriseWechatCallbackService.appCallback(req, callbackCode, reqBody);
+//        return "success";
+//    }
+
+}

+ 36 - 0
src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomContactController.java

@@ -0,0 +1,36 @@
+package com.qqflow.engine.domain.ewechat.controller;
+
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatDepartment;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatMember;
+import com.qqflow.engine.domain.ewechat.service.WecomContactSyncService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@Tag(name = "企微通讯录")
+@RestController
+@RequestMapping("/wecom/contact")
+@RequiredArgsConstructor
+public class WecomContactController {
+
+    private final WecomContactSyncService syncService;
+
+    @GetMapping("/departments")
+    @Operation(summary = "获取子部门列表(懒加载同步)")
+    public Result<List<EnterpriseWechatDepartment>> departments(@RequestParam(defaultValue = "0") Long parentId) {
+        return Result.ok(syncService.listSubDepartments(parentId));
+    }
+
+    @GetMapping("/members")
+    @Operation(summary = "获取部门成员列表(懒加载同步)")
+    public Result<List<EnterpriseWechatMember>> members(@RequestParam Long departmentId) {
+        return Result.ok(syncService.listDepartmentMembers(departmentId));
+    }
+}

+ 16 - 0
src/main/java/com/qqflow/engine/domain/ewechat/dto/EnterpriseCorpDto.java

@@ -0,0 +1,16 @@
+package com.qqflow.engine.domain.ewechat.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/4/18
+ */
+@Getter
+@Setter
+public class EnterpriseCorpDto {
+    private String corpId;
+
+    private String suiteId;
+}

+ 22 - 0
src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatInfoType.java

@@ -0,0 +1,22 @@
+package com.qqflow.engine.domain.ewechat.enums;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+public enum EnterpriseWechatInfoType {
+    /** 创建授权 */
+    create_auth,
+
+    /** 取消授权 */
+    cancel_auth,
+
+    /** 授权额外信息 */
+    auth_external_contact,
+
+    /** ticket更新 */
+    suite_ticket,
+
+    /** 重置永久授权码 */
+    reset_permanent_code,
+}

+ 19 - 0
src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatMsgEventType.java

@@ -0,0 +1,19 @@
+package com.qqflow.engine.domain.ewechat.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ * @see <a href='https://developer.work.weixin.qq.com/document/path/94699#%E4%BA%8B%E4%BB%B6%E6%B6%88%E6%81%AF'>事件类型</a>
+ */
+@Getter
+@RequiredArgsConstructor
+public enum EnterpriseWechatMsgEventType {
+    enter_session,
+    msg_send_fail,
+    servicer_status_change,
+    user_recall_msg
+    ;
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/enums/EnterpriseWechatMsgType.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.enums;
+
+import com.qqflow.engine.common.enums.StringIEnum;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@RequiredArgsConstructor
+public enum EnterpriseWechatMsgType implements StringIEnum {
+    event,
+    image,
+    text,
+    file
+    ;
+
+
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface EnterpriseWechatCorpMapper extends BaseMapper<EnterpriseWechatCorp> {
+    default EnterpriseWechatCorp selectByCorpIdAndSuiteId(String corpId, String suiteId) {
+        return this.selectOne(
+                Wrappers.<EnterpriseWechatCorp>lambdaQuery().eq(EnterpriseWechatCorp::getCorpId, corpId).eq(EnterpriseWechatCorp::getSuiteId, suiteId)
+                        .last("LIMIT 1"));
+    }
+
+    default List<EnterpriseWechatCorp> selectAllCorps() {
+        return this.selectList(Wrappers.emptyWrapper());
+    }
+}

+ 27 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatCorpMapper">
+  <resultMap id="BaseResultMap" type="com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp">
+    <!--@mbg.generated-->
+    <!--@Table enterprise_wechat_corp-->
+    <id column="id" jdbcType="BIGINT" property="id" />
+    <result column="created_time" jdbcType="TIMESTAMP" property="createdTime" />
+    <result column="updated_time" jdbcType="TIMESTAMP" property="updatedTime" />
+    <result column="permanent_code" jdbcType="VARCHAR" property="permanentCode" />
+    <result column="callback_code" jdbcType="VARCHAR" property="callbackCode" />
+    <result column="suite_id" jdbcType="VARCHAR" property="suiteId" />
+    <result column="corp_id" jdbcType="VARCHAR" property="corpId" />
+    <result column="corp_name" jdbcType="VARCHAR" property="corpName" />
+    <result column="auth_user_id" jdbcType="VARCHAR" property="authUserId" />
+    <result column="auth_open_user_id" jdbcType="VARCHAR" property="authOpenUserId" />
+    <result column="register_code" jdbcType="VARCHAR" property="registerCode" />
+    <result column="template_id" jdbcType="VARCHAR" property="templateId" />
+    <result column="state" jdbcType="VARCHAR" property="state" />
+  </resultMap>
+  <sql id="Base_Column_List">
+    <!--@mbg.generated-->
+    id, created_by, created_time, updated_by, updated_time, permanent_code, callback_code, 
+    suite_id, corp_id, corp_name, auth_user_id, auth_open_user_id, register_code, template_id, 
+    `state`
+  </sql>
+</mapper>

+ 9 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatDepartmentMapper.java

@@ -0,0 +1,9 @@
+package com.qqflow.engine.domain.ewechat.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatDepartment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface EnterpriseWechatDepartmentMapper extends BaseMapper<EnterpriseWechatDepartment> {
+}

+ 9 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatMemberMapper.java

@@ -0,0 +1,9 @@
+package com.qqflow.engine.domain.ewechat.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatMember;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface EnterpriseWechatMemberMapper extends BaseMapper<EnterpriseWechatMember> {
+}

+ 14 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMapper.java

@@ -0,0 +1,14 @@
+package com.qqflow.engine.domain.ewechat.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSession;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface EnterpriseWechatSessionMapper extends BaseMapper<EnterpriseWechatSession> {
+    default EnterpriseWechatSession selectByKfidAndUserId(String kfid, String userId) {
+        return this.selectOne(Wrappers.<EnterpriseWechatSession>lambdaQuery().eq(EnterpriseWechatSession::getOpenKfid, kfid)
+                .eq(EnterpriseWechatSession::getExternalUserid, userId).last("LIMIT 1"));
+    }
+}

+ 19 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMapper.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatSessionMapper">
+  <resultMap id="BaseResultMap" type="com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSession">
+    <!--@mbg.generated-->
+    <!--@Table enterprise_wechat_session-->
+    <id column="id" jdbcType="BIGINT" property="id" />
+    <result column="created_time" jdbcType="TIMESTAMP" property="createdTime" />
+    <result column="updated_time" jdbcType="TIMESTAMP" property="updatedTime" />
+    <result column="open_kfid" jdbcType="VARCHAR" property="openKfid" />
+    <result column="external_userid" jdbcType="VARCHAR" property="externalUserid" />
+    <result column="corp_id" jdbcType="VARCHAR" property="corpId" />
+  </resultMap>
+  <sql id="Base_Column_List">
+    <!--@mbg.generated-->
+    id, created_by, created_time, updated_by, updated_time, open_kfid, external_userid, 
+    corp_id
+  </sql>
+</mapper>

+ 28 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMsgMapper.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.ewechat.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.qqflow.engine.common.po.BaseModel;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSessionMsg;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface EnterpriseWechatSessionMsgMapper extends BaseMapper<EnterpriseWechatSessionMsg> {
+    default boolean existMsg(Long sessionId) {
+        return selectCount(Wrappers.<EnterpriseWechatSessionMsg>lambdaQuery().eq(EnterpriseWechatSessionMsg::getSessionId, sessionId)
+                .eq(EnterpriseWechatSessionMsg::getOrigin, 5)) > 0;
+    }
+
+    default boolean existMsg(String msgid){
+        return selectCount(Wrappers.<EnterpriseWechatSessionMsg>lambdaQuery()
+                .eq(EnterpriseWechatSessionMsg::getMsgId, msgid)) > 0;
+    }
+
+    /** 不包括欢迎语 */
+    default List<EnterpriseWechatSessionMsg> selectBySessionId(Long sessionId){
+        return selectList(Wrappers.<EnterpriseWechatSessionMsg>lambdaQuery().eq(EnterpriseWechatSessionMsg::getSessionId, sessionId)
+                .isNull(EnterpriseWechatSessionMsg::getCode).orderByDesc(BaseModel::getId).last("limit 10"));
+    }
+}

+ 24 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSessionMsgMapper.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatSessionMsgMapper">
+  <resultMap id="BaseResultMap" type="com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSessionMsg">
+    <!--@mbg.generated-->
+    <!--@Table enterprise_wechat_session_msg-->
+    <id column="id" jdbcType="BIGINT" property="id" />
+    <result column="created_time" jdbcType="TIMESTAMP" property="createdTime" />
+    <result column="updated_time" jdbcType="TIMESTAMP" property="updatedTime" />
+    <result column="session_id" jdbcType="BIGINT" property="sessionId" />
+    <result column="msg_id" jdbcType="VARCHAR" property="msgId" />
+    <result column="msg_type" jdbcType="VARCHAR" property="msgType" />
+    <result column="media_id" jdbcType="VARCHAR" property="mediaId" />
+    <result column="media_url" jdbcType="VARCHAR" property="mediaUrl" />
+    <result column="code" jdbcType="VARCHAR" property="code" />
+    <result column="value" jdbcType="VARCHAR" property="value" />
+    <result column="origin" jdbcType="TINYINT" property="origin" />
+  </resultMap>
+  <sql id="Base_Column_List">
+    <!--@mbg.generated-->
+    id, created_by, created_time, updated_by, updated_time, session_id, msg_id, msg_type,
+    media_id, media_url, code, `value`, origin
+  </sql>
+</mapper>

+ 20 - 0
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatSuiteMapper.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatSuiteMapper">
+  <resultMap id="BaseResultMap" type="com.qqflow.engine.domain.ewechat.po.EnterpriseWechatSuite">
+    <!--@mbg.generated-->
+    <!--@Table enterprise_wechat_suite-->
+    <id column="id" jdbcType="BIGINT" property="id" />
+    <result column="created_time" jdbcType="TIMESTAMP" property="createdTime" />
+    <result column="updated_time" jdbcType="TIMESTAMP" property="updatedTime" />
+    <result column="name" jdbcType="VARCHAR" property="name" />
+    <result column="suite_id" jdbcType="VARCHAR" property="suiteId" />
+    <result column="suite_secret" jdbcType="VARCHAR" property="suiteSecret" />
+    <result column="callback_code" jdbcType="VARCHAR" property="callbackCode" />
+  </resultMap>
+  <sql id="Base_Column_List">
+    <!--@mbg.generated-->
+    id, created_by, created_time, updated_by, updated_time, `name`, suite_id, suite_secret, 
+    callback_code
+  </sql>
+</mapper>

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/EnterpriseWechatEvent.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise;
+
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatInfoType;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+public class EnterpriseWechatEvent extends ApplicationEvent {
+    private String callbackXml;
+
+    public EnterpriseWechatEvent(EnterpriseWechatInfoType infoType, String callbackXml) {
+        super(infoType);
+        this.callbackXml = callbackXml;
+    }
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatAuthNotice.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+public class EnterpriseWechatAuthNotice extends EnterpriseWechatDecryptBody {
+    /** 临时授权码,最长为512字节。用于获取企业永久授权码。10分钟内有效 */
+    private String authCode;
+
+    /** 构造授权链接指定的state参数,暂时没见过 */
+    private String state;
+
+    /** 额外信息,暂时没见过 */
+    private String extraInfo;
+}

+ 25 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatCallbackReq.java

@@ -0,0 +1,25 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/14
+ * @see <a href="hhttps://developer.work.weixin.qq.com/document/path/96238">企业微信网页授权</a>
+ */
+@Getter
+@Setter
+public class EnterpriseWechatCallbackReq {
+    /** 企业微信加密签名 */
+    private String msg_signature;
+
+    /** 时间戳 */
+    private Integer timestamp;
+
+    /** 随机数 */
+    private String nonce;
+
+    /** 加密的字符串。需要解密得到消息内容明文,解密后有random、msg_len、msg、receiveid四个字段,其中msg即为消息内容明文 */
+    private String echostr;
+}

+ 28 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatCallbackXmlReq.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/14
+ * @see <a href="hhttps://developer.work.weixin.qq.com/document/path/96238">企业微信网页授权</a>
+ */
+@Getter
+@Setter
+@JacksonXmlRootElement
+public class EnterpriseWechatCallbackXmlReq {
+    /** 企业微信的CorpID,当为第三方应用回调事件时,CorpID的内容为suiteid */
+    @JacksonXmlProperty(localName = "ToUserName")
+    private String toUserName;
+
+    /** 接收的应用id,可在应用的设置页面获取。仅应用相关的回调会带该字段。 */
+    @JacksonXmlProperty(localName = "AgentID")
+    private String agentId;
+
+    /** 企业微信加密签名,msg_signature结合了企业填写的token、请求中的timestamp、nonce参数、加密的消息体 */
+    @JacksonXmlProperty(localName = "Encrypt")
+    private String msgEncrypt;
+}

+ 24 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatDecryptBody.java

@@ -0,0 +1,24 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatInfoType;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
+public class EnterpriseWechatDecryptBody {
+    /** suiteId 第三方应用id或者代开发应用模板id。第三方应用以ww或wx开头应用id(对应于旧的以tj开头的套件id);代开发应用以dk开头 */
+    protected String suiteId;
+
+    protected EnterpriseWechatInfoType infoType;
+
+    protected Long timestamp;
+}

+ 35 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatMsgDecryptBody.java

@@ -0,0 +1,35 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 消息回调解密后请求体
+ * @author WuWenYi
+ * @since 2025/3/20
+ * @see <a href='https://developer.work.weixin.qq.com/document/path/94699'>企业微信回调消息解密接口</a>
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
+public class EnterpriseWechatMsgDecryptBody {
+    /** 企业微信CorpID */
+    private String toUserName;
+
+    private Long createTime;
+
+    /** 消息的类型,此时固定为:event */
+    private String msgType;
+
+    /** 事件类型,此时固定为:kf_msg_or_event */
+    private String event;
+
+    /** 调用拉取消息接口时,需要传此token,用于校验请求的合法性 */
+    private String token;
+
+    /** 有新消息的客服账号。可通过sync_msg接口指定open_kfid获取此客服账号的消息 */
+    private String openKfId;
+}

+ 15 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/callback/EnterpriseWechatSuiteTicket.java

@@ -0,0 +1,15 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.callback;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+public class EnterpriseWechatSuiteTicket extends EnterpriseWechatDecryptBody {
+    /** Ticket内容,最长为512字节 */
+    private String suiteTicket;
+}

+ 27 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatAddCsReq.java

@@ -0,0 +1,27 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * 添加客服
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor(staticName = "of")
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatAddCsReq {
+    /** 客服名称 */
+    private String name;
+
+    /** 头像,素材ID*/
+    private String mediaId;
+}

+ 33 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatChangeSessionReq.java

@@ -0,0 +1,33 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatChangeSessionReq {
+    private String openKfid;
+
+    private String externalUserid;
+
+    /** 变更的目标状态 */
+    private Integer serviceState;
+
+    /** 暂时用不到,接待人员的userid。第三方应用填密文userid,即open_userid。当state=3时要求必填,接待人员须处于“正在接待”中。 */
+    private String servicerUserid;
+
+    public static EnterpriseWechatChangeSessionReq of(String openKfid, String externalUserid, Integer serviceState) {
+        EnterpriseWechatChangeSessionReq req = new EnterpriseWechatChangeSessionReq();
+        req.setOpenKfid(openKfid);
+        req.setExternalUserid(externalUserid);
+        req.setServiceState(serviceState);
+        return req;
+    }
+}

+ 28 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatCsUrlReq.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@AllArgsConstructor(staticName = "of")
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatCsUrlReq {
+    /** 客服账号ID */
+    private String openKfid;
+
+    /**
+     * 场景值,
+     * 字符串类型,由开发者自定义。
+     * 不多于32字节
+     * 非必填
+     */
+    private String scene;
+}

+ 12 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatListIdReq.java

@@ -0,0 +1,12 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import lombok.Data;
+
+@Data
+public class EnterpriseWechatListIdReq {
+    /** 分页游标,首次不填 */
+    private String cursor;
+
+    /** 分页数据量,1~10000 */
+    private Integer limit;
+}

+ 28 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendMsgReq.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatMsgType;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public abstract class EnterpriseWechatSendMsgReq {
+    /** EXTERNAL_USERID,客户id */
+    private String touser;
+
+    /** 客服id */
+    private String openKfid;
+
+    /** 消息类型 */
+    private EnterpriseWechatMsgType msgtype;
+
+    public abstract String getMsgValue();
+}

+ 39 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendOnEventMsgReq.java

@@ -0,0 +1,39 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 发送欢迎语等事件响应消息请求体(目前只做文本
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+public class EnterpriseWechatSendOnEventMsgReq {
+    /** 事件响应消息对应的code。通过事件回调下发,仅可使用一次。 */
+    private String code;
+
+    /** 消息ID。如果请求参数指定了msgid,则原样返回,否则系统自动生成并返回。不多于32字节 */
+    private String msgid;
+
+    /** 只有两种:text-文本,msgmenu-菜单 */
+    private String msgtype;
+
+    private Text text;
+
+    @Getter
+    @Setter
+    private static class Text{
+        private String content;
+    }
+
+    public static EnterpriseWechatSendOnEventMsgReq of(String code,String content){
+        EnterpriseWechatSendOnEventMsgReq req = new EnterpriseWechatSendOnEventMsgReq();
+        req.code = code;
+        req.msgtype = "text";
+        req.text = new Text();
+        req.text.content = content;
+        return req;
+    }
+}

+ 79 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendTextMsgReq.java

@@ -0,0 +1,79 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Data
+public class EnterpriseWechatSendTextMsgReq {
+
+    /** 指定接收消息的成员,多个用'|'分隔,最多1000个。@all表示全部成员 */
+    private String touser;
+
+    /** 指定接收消息的部门,多个用'|'分隔,最多100个 */
+    private String toparty;
+
+    /** 指定接收消息的标签,多个用'|'分隔,最多100个 */
+    private String totag;
+
+    /** 消息类型,固定为text */
+    private String msgtype = "text";
+
+    /** 企业应用的id */
+    private Integer agentid;
+
+    /** 文本内容 */
+    @JsonProperty("text")
+    private TextContent text;
+
+    /** 是否保密消息,0-可分享 1-水印,默认0 */
+    private Integer safe = 0;
+
+    @JsonProperty("enable_id_trans")
+    private Integer enableIdTrans = 0;
+
+    @JsonProperty("enable_duplicate_check")
+    private Integer enableDuplicateCheck = 0;
+
+    @JsonProperty("duplicate_check_interval")
+    private Integer duplicateCheckInterval = 1800;
+
+    @Data
+    public static class TextContent {
+        private String content;
+
+        public static TextContent of(String content) {
+            TextContent tc = new TextContent();
+            tc.setContent(content);
+            return tc;
+        }
+    }
+
+    public static EnterpriseWechatSendTextMsgReq toUser(String userId, Integer agentId, String content) {
+        EnterpriseWechatSendTextMsgReq req = new EnterpriseWechatSendTextMsgReq();
+        req.setTouser(userId);
+        req.setAgentid(agentId);
+        req.setText(TextContent.of(content));
+        return req;
+    }
+
+    /** 创建发送给指定用户的消息 */
+    public static EnterpriseWechatSendTextMsgReq toUsers(List<String> userIds, Integer agentId, String content) {
+        EnterpriseWechatSendTextMsgReq req = new EnterpriseWechatSendTextMsgReq();
+        req.setTouser(userIds.stream().collect(Collectors.joining("|")));
+        req.setAgentid(agentId);
+        req.setText(TextContent.of(content));
+        return req;
+    }
+
+    /** 创建发送给全部成员的消息 */
+    public static EnterpriseWechatSendTextMsgReq toAll(Integer agentId, String content) {
+        EnterpriseWechatSendTextMsgReq req = new EnterpriseWechatSendTextMsgReq();
+        req.setTouser("@all");
+        req.setAgentid(agentId);
+        req.setText(TextContent.of(content));
+        return req;
+    }
+}

+ 23 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSessionReq.java

@@ -0,0 +1,23 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@AllArgsConstructor(staticName = "of")
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSessionReq {
+    /** 客服Id */
+    private String openKfid;
+
+    /** 微信客户的external_userid */
+    private String externalUserid;
+}

+ 32 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSuiteAccessTokenReq.java

@@ -0,0 +1,32 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSuiteAccessTokenReq {
+    /** 第三方应用id或者代开发应用模板id。第三方应用以ww或wx开头应用id(对应于旧的以tj开头的套件id);代开发应用以dk开头 */
+    private String suiteId;
+
+    /** 第三方应用secret 或者代开发应用模板secret */
+    private String suiteSecret;
+
+    /** 企业微信后台推送的ticket */
+    private String suiteTicket;
+
+    public static EnterpriseWechatSuiteAccessTokenReq of(String suiteId, String suiteSecret, String suiteTicket){
+        EnterpriseWechatSuiteAccessTokenReq req = new EnterpriseWechatSuiteAccessTokenReq();
+        req.setSuiteId(suiteId);
+        req.setSuiteSecret(suiteSecret);
+        req.setSuiteTicket(suiteTicket);
+        return req;
+    }
+}

+ 37 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSyncMsgReq.java

@@ -0,0 +1,37 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSyncMsgReq {
+    /** 上一次调用时返回的next_cursor,第一次拉取可以不填。若不填,从3天内最早的消息开始返回。 */
+    private String cursor;
+
+    /** 回调事件返回的token字段,10分钟内有效;可不填,如果不填接口有严格的频率限制。 */
+    private String token;
+
+    private Integer limit =1000;
+
+    /** 语音消息类型,0-Amr 1-Silk,默认0。可通过该参数控制返回的语音格式,开发者可按需选择自己程序支持的一种格式 */
+    private Integer voiceFormat = 0;
+
+    /** 指定拉取某个客服账号的消息 */
+    private String openKfid;
+
+    public static EnterpriseWechatSyncMsgReq of(String cursor, String token, String openKfid) {
+        EnterpriseWechatSyncMsgReq req = new EnterpriseWechatSyncMsgReq();
+        req.setCursor(cursor);
+        req.setToken(token);
+        req.setOpenKfid(openKfid);
+        return req;
+    }
+}

+ 17 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatUseridByMobileReq.java

@@ -0,0 +1,17 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class EnterpriseWechatUseridByMobileReq {
+    /** 手机号 */
+    private String mobile;
+
+    public static EnterpriseWechatUseridByMobileReq of(String mobile) {
+        EnterpriseWechatUseridByMobileReq req = new EnterpriseWechatUseridByMobileReq();
+        req.setMobile(mobile);
+        return req;
+    }
+}

+ 41 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/msg/EnterpriseWechatSendImgMsgReq.java

@@ -0,0 +1,41 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req.msg;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatMsgType;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendMsgReq;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/24
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSendImgMsgReq extends EnterpriseWechatSendMsgReq {
+    private Body image;
+
+    @Override
+    public String getMsgValue() {
+        return image.getMediaId();
+    }
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class Body {
+        private String mediaId;
+    }
+
+    public static EnterpriseWechatSendImgMsgReq of(String toUser,String openKfid, String mediaId) {
+        EnterpriseWechatSendImgMsgReq req = new EnterpriseWechatSendImgMsgReq();
+        req.setTouser(toUser);
+        req.setOpenKfid(openKfid);
+        req.setMsgtype(EnterpriseWechatMsgType.image);
+        req.setImage(new Body());
+        req.getImage().setMediaId(mediaId);
+        return req;
+    }
+}

+ 40 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/msg/EnterpriseWechatSendTextMsgReq.java

@@ -0,0 +1,40 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req.msg;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatMsgType;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendMsgReq;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSendTextMsgReq extends EnterpriseWechatSendMsgReq {
+    private Body text;
+
+    @Override
+    public String getMsgValue() {
+        return text.getContent();
+    }
+
+    @Getter
+    @Setter
+    public static class Body {
+        private String content;
+    }
+
+    public static EnterpriseWechatSendTextMsgReq of(String toUser,String openKfid, String content) {
+        EnterpriseWechatSendTextMsgReq req = new EnterpriseWechatSendTextMsgReq();
+        req.setTouser(toUser);
+        req.setOpenKfid(openKfid);
+        req.setMsgtype(EnterpriseWechatMsgType.text);
+        req.setText(new Body());
+        req.getText().setContent(content);
+        return req;
+    }
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatAccessTokenRsp.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatAccessTokenRsp extends EnterpriseWechatBaseRsp {
+    /** 获取到的凭证,最长为512字节 */
+    private String accessToken;
+
+    /** 有效期(秒) */
+    private Integer expiresIn;
+}

+ 19 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatAddCsRsp.java

@@ -0,0 +1,19 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 添加客服
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatAddCsRsp extends EnterpriseWechatBaseRsp {
+    /** 新创建的客服账号ID */
+    private String openKfid;
+}

+ 16 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatBaseRsp.java

@@ -0,0 +1,16 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+public class EnterpriseWechatBaseRsp {
+    private Integer errcode;
+
+    private String errmsg;
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatChangeSessionRsp.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatChangeSessionRsp extends EnterpriseWechatBaseRsp {
+    /**
+     * 用于发送响应事件消息的code,将会话初次变更为service_state为2和3时,返回回复语code,service_state为4时,返回结束语code。
+     * 可用该code调用发送事件响应消息接口给客户发送事件响应消息
+     */
+    private String msgCode;
+}

+ 18 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatCsUrlRsp.java

@@ -0,0 +1,18 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatCsUrlRsp extends EnterpriseWechatBaseRsp {
+    /** 客服链接,开发者可将该链接嵌入到H5页面中,用户点击链接即可向对应的微信客服账号发起咨询。开发者也可根据该url自行生成需要的二维码图片 */
+    private String url;
+}

+ 31 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatDepartmentListRsp.java

@@ -0,0 +1,31 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatDepartmentListRsp extends EnterpriseWechatBaseRsp {
+    /** 部门列表 */
+    private List<Department> department;
+
+    @Data
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class Department {
+        /** 部门id */
+        private Long id;
+        /** 部门名称 */
+        private String name;
+        /** 英文名称 */
+        private String nameEn;
+        /** 部门负责人UserID列表 */
+        private List<String> departmentLeader;
+        /** 父部门id,根部门为1 */
+        private Long parentid;
+        /** 排序值 */
+        private Long order;
+    }
+}

+ 26 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatListIdRsp.java

@@ -0,0 +1,26 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatListIdRsp extends EnterpriseWechatBaseRsp {
+    /** 分页游标,为空表示已无更多数据 */
+    private String nextCursor;
+
+    /** 用户-部门关系列表 */
+    private List<DeptUser> deptUser;
+
+    @Data
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class DeptUser {
+        /** open_userid */
+        private String openUserid;
+        /** 所属部门ID */
+        private Integer department;
+    }
+}

+ 52 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatPermanentCodeRsp.java

@@ -0,0 +1,52 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ * @see <a href="https://developer.work.weixin.qq.com/document/path/100776">企业微信永久授权码获取</a>
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatPermanentCodeRsp extends EnterpriseWechatBaseRsp {
+    private String permanentCode;
+
+    private AuthCorpInfo authCorpInfo;
+
+    private AuthUserInfo authUserInfo;
+
+    private RegisterCodeInfo registerCodeInfo;
+
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class AuthCorpInfo{
+        private String corpid;
+        private String corpName;
+    }
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class AuthUserInfo{
+        private String userid;
+        private String openUserid;
+        private String name;
+        private String avatar;
+    }
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class RegisterCodeInfo{
+        private String registerCode;
+        private String templateId;
+        private String state;
+    }
+}

+ 14 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSendMsgRsp.java

@@ -0,0 +1,14 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+public class EnterpriseWechatSendMsgRsp extends EnterpriseWechatBaseRsp {
+    private String msgid;
+}

+ 22 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSendTextMsgRsp.java

@@ -0,0 +1,22 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Data;
+
+@Data
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSendTextMsgRsp extends EnterpriseWechatBaseRsp {
+    /** 不合法的userid,不区分大小写,统一转为小写 */
+    private String invaliduser;
+    /** 不合法的partyid */
+    private String invalidparty;
+    /** 不合法的标签id */
+    private String invalidtag;
+    /** 没有基础接口许可(包含已过期)的userid */
+    private String unlicenseduser;
+    /** 消息id,用于撤回应用消息 */
+    private String msgid;
+    /** 仅特定模板卡片消息返回,72小时内有效,且只能使用一次 */
+    private String responseCode;
+}

+ 28 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSessionRsp.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSessionRsp extends EnterpriseWechatBaseRsp {
+    /**
+     * 当前的会话状态
+     * 0	未处理	新会话接入(客户发消息咨询)。可选择:1.直接用API自动回复消息。2.放进待接入池等待接待人员接待。3.指定接待人员(接待人员须处于“正在接待”中,下同)进行接待
+     * 1	由智能助手接待	可使用API回复消息。可选择转入待接入池或者指定接待人员处理。
+     * 2	待接入池排队中	在待接入池中排队等待接待人员接入。可选择转为指定人员接待
+     * 3	由人工接待	人工接待中。可选择转接给其他接待人员处理或者结束会话。
+     * 4	已结束/未开始	会话已经结束或未开始(客户进入会话,还没上行消息)。不允许通过API变更会话状态,客户发消息咨询后会话状态变为“未处理”。接待人员通过客户端在已结束会话中成功发送消息后,会话状态变为“由人工接待”,此时会产生会话状态变更回调事件(4-重新接入已结束/已转接会话)。
+     */
+    private Integer serviceState;
+
+    /** 接待人员的userid。第三方应用为密文userid,即open_userid。仅当state=3时有效 */
+    private String servicerUserid;
+}

+ 22 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSuiteAccessTokenRsp.java

@@ -0,0 +1,22 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/18
+ */
+@Getter
+@Setter
+@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSuiteAccessTokenRsp extends EnterpriseWechatBaseRsp {
+    /** 第三方或者代开发应用access_token,最长为512字节 */
+    private String suiteAccessToken;
+
+    /** 有效期(秒) */
+    private Integer expiresIn;
+}

+ 31 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatSyncMsgRsp.java

@@ -0,0 +1,31 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg.EnterpriseWechatMsg;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatSyncMsgRsp extends EnterpriseWechatBaseRsp {
+    /** 下次调用带上该值,则从当前的位置继续往后拉,以实现增量拉取。 */
+    private String nextCursor;
+
+    /**
+     * 是否还有更多数据。0-否;1-是。
+     * 不能通过判断msg_list是否空来停止拉取,可能会出现has_more为1,而msg_list为空的情况
+     */
+    private Boolean hasMore;
+
+    /** 消息列表 */
+    private List<EnterpriseWechatMsg> msgList;
+
+}

+ 24 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUploadRsp.java

@@ -0,0 +1,24 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/19
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatUploadRsp extends EnterpriseWechatBaseRsp {
+    /** 媒体文件类型,分别有图片(image)、语音(voice)、视频(video),普通文件(file) */
+    private String type;
+
+    /** 媒体文件上传后获取的唯一标识,3天内有效 */
+    private String mediaId;
+
+    /** 媒体文件上传时间戳 */
+    private Long createdAt;
+}

+ 27 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUserListRsp.java

@@ -0,0 +1,27 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatUserListRsp extends EnterpriseWechatBaseRsp {
+    /** 成员列表 */
+    private List<User> userlist;
+
+    @Data
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class User {
+        /** 成员UserID */
+        private String userid;
+        /** 成员名称 */
+        private String name;
+        /** 所属部门ID列表 */
+        private List<Long> department;
+        /** 全局唯一open_userid */
+        private String openUserid;
+    }
+}

+ 14 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUseridByMobileRsp.java

@@ -0,0 +1,14 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatUseridByMobileRsp extends EnterpriseWechatBaseRsp {
+    /** 成员UserID */
+    private String userid;
+}

+ 18 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatEventMsg.java

@@ -0,0 +1,18 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg.event.EnterpriseWechatMsgEvent;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatEventMsg extends EnterpriseWechatMsg {
+    private EnterpriseWechatMsgEvent event;
+}

+ 25 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatFileMsg.java

@@ -0,0 +1,25 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatFileMsg extends EnterpriseWechatMsg {
+    private Body file;
+
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class Body {
+        private String mediaId;
+    }
+}

+ 25 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatImageMsg.java

@@ -0,0 +1,25 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/21
+ */
+@Getter
+@Setter
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public class EnterpriseWechatImageMsg extends EnterpriseWechatMsg {
+    private Body image;
+
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class Body {
+        private String mediaId;
+    }
+}

+ 46 - 0
src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/msg/EnterpriseWechatMsg.java

@@ -0,0 +1,46 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp.msg;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.qqflow.engine.domain.ewechat.enums.EnterpriseWechatMsgType;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author WuWenYi
+ * @since 2025/3/20
+ */
+@Getter
+@Setter
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "msgtype", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
+@JsonSubTypes({
+        @JsonSubTypes.Type(value = EnterpriseWechatEventMsg.class, name = "event"),
+        @JsonSubTypes.Type(value = EnterpriseWechatTextMsg.class, name = "text"),
+        @JsonSubTypes.Type(value = EnterpriseWechatImageMsg.class, name = "image"),
+        @JsonSubTypes.Type(value = EnterpriseWechatFileMsg.class, name = "file"),
+})
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public abstract class EnterpriseWechatMsg {
+    private String msgid;
+
+    /** 客服账号ID(msgtype为event,该字段不返回) */
+    private String openKfid;
+
+    /** 客户UserID(msgtype为event,该字段不返回) */
+    private String externalUserid;
+
+    private Long sendTime;
+
+    /** 消息来源。3-微信客户发送的消息 4-系统推送的事件消息 5-接待人员在企业微信客户端发送的消息 */
+    private Integer origin;
+
+    /** 从企业微信给客户发消息的接待人员userid(即仅origin为5才返回;msgtype为event,该字段不返回) */
+    private String servicerUserid;
+
+    /** 消息类型 */
+    private EnterpriseWechatMsgType msgtype;
+
+}

Някои файлове не бяха показани, защото твърде много файлове са промени