Просмотр исходного кода

feat: 附件持久化、历史附件查看、预览与待办权限提示优化

ye-zhaojia 4 дней назад
Родитель
Сommit
813b3e40c4
95 измененных файлов с 3832 добавлено и 446 удалено
  1. 5 6
      README.md
  2. 10 16
      pom.xml
  3. 24 0
      src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java
  4. 98 6
      src/main/java/com/qqflow/engine/common/controller/FileUploadController.java
  5. 201 0
      src/main/java/com/qqflow/engine/common/service/ExcelParseService.java
  6. 35 0
      src/main/java/com/qqflow/engine/common/util/JwtUtils.java
  7. 2 0
      src/main/java/com/qqflow/engine/config/EmbeddedRedisConfig.java
  8. 2 0
      src/main/java/com/qqflow/engine/config/MyBatisPlusConfig.java
  9. 2 0
      src/main/java/com/qqflow/engine/config/RedisConfig.java
  10. 17 1
      src/main/java/com/qqflow/engine/config/WebConfig.java
  11. 50 5
      src/main/java/com/qqflow/engine/config/security/JwtAuthenticationFilter.java
  12. 3 1
      src/main/java/com/qqflow/engine/config/security/LoginUser.java
  13. 1 0
      src/main/java/com/qqflow/engine/config/security/SecurityConfig.java
  14. 1 12
      src/main/java/com/qqflow/engine/domain/flow/assembler/ApprovalTaskAssembler.java
  15. 69 0
      src/main/java/com/qqflow/engine/domain/flow/assembler/ApprovalTaskDTOAssembler.java
  16. 2 0
      src/main/java/com/qqflow/engine/domain/flow/assembler/ProcessDefinitionAssembler.java
  17. 88 0
      src/main/java/com/qqflow/engine/domain/flow/controller/AnalysisController.java
  18. 30 4
      src/main/java/com/qqflow/engine/domain/flow/controller/ApprovalTaskController.java
  19. 3 0
      src/main/java/com/qqflow/engine/domain/flow/controller/ProcessDefinitionController.java
  20. 32 3
      src/main/java/com/qqflow/engine/domain/flow/controller/ProcessInstanceController.java
  21. 13 0
      src/main/java/com/qqflow/engine/domain/flow/dto/ApprovalTaskDTO.java
  22. 72 0
      src/main/java/com/qqflow/engine/domain/flow/dto/AttachmentDTO.java
  23. 22 0
      src/main/java/com/qqflow/engine/domain/flow/dto/BatchTaskDTO.java
  24. 5 0
      src/main/java/com/qqflow/engine/domain/flow/dto/ProcessDefinitionDTO.java
  25. 3 0
      src/main/java/com/qqflow/engine/domain/flow/dto/ProcessInstanceDTO.java
  26. 36 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/AnalysisOverviewDTO.java
  27. 33 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/NodeStayStatDTO.java
  28. 35 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/ProcessEfficiencyDTO.java
  29. 21 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/StatusDistributionDTO.java
  30. 47 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/StuckInstanceDTO.java
  31. 24 0
      src/main/java/com/qqflow/engine/domain/flow/dto/analysis/TrendDTO.java
  32. 1 3
      src/main/java/com/qqflow/engine/domain/flow/enums/ApprovalAction.java
  33. 2 1
      src/main/java/com/qqflow/engine/domain/flow/enums/ApprovalResult.java
  34. 0 1
      src/main/java/com/qqflow/engine/domain/flow/enums/ProcessStatus.java
  35. 4 1
      src/main/java/com/qqflow/engine/domain/flow/event/TaskAssignedEvent.java
  36. 86 0
      src/main/java/com/qqflow/engine/domain/flow/job/OverdueCheckJob.java
  37. 1 0
      src/main/java/com/qqflow/engine/domain/flow/listener/NotificationEventListener.java
  38. 81 0
      src/main/java/com/qqflow/engine/domain/flow/mapper/AnalysisMapper.java
  39. 5 7
      src/main/java/com/qqflow/engine/domain/flow/mapper/ApprovalTaskMapper.java
  40. 9 0
      src/main/java/com/qqflow/engine/domain/flow/mapper/AttachmentMapper.java
  41. 2 16
      src/main/java/com/qqflow/engine/domain/flow/mapper/ProcessInstanceMapper.java
  42. 8 0
      src/main/java/com/qqflow/engine/domain/flow/po/ApprovalRecord.java
  43. 6 0
      src/main/java/com/qqflow/engine/domain/flow/po/ApprovalTask.java
  44. 74 0
      src/main/java/com/qqflow/engine/domain/flow/po/Attachment.java
  45. 4 0
      src/main/java/com/qqflow/engine/domain/flow/po/ProcessDefinition.java
  46. 77 0
      src/main/java/com/qqflow/engine/domain/flow/service/AnalysisService.java
  47. 6 1
      src/main/java/com/qqflow/engine/domain/flow/service/ApprovalTaskService.java
  48. 15 1
      src/main/java/com/qqflow/engine/domain/flow/service/NotificationService.java
  49. 6 1
      src/main/java/com/qqflow/engine/domain/flow/service/ProcessInstanceService.java
  50. 181 0
      src/main/java/com/qqflow/engine/domain/flow/service/impl/AnalysisServiceImpl.java
  51. 40 0
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskHelper.java
  52. 297 46
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskServiceImpl.java
  53. 320 88
      src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java
  54. 10 0
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessDefinitionServiceImpl.java
  55. 232 15
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java
  56. 120 20
      src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java
  57. 0 68
      src/main/java/com/qqflow/engine/domain/flow/statemachine/ProcessInstanceStateMachineConfig.java
  58. 26 6
      src/main/java/com/qqflow/engine/domain/system/controller/AuthController.java
  59. 13 1
      src/main/java/com/qqflow/engine/domain/system/controller/SysDeptController.java
  60. 57 0
      src/main/java/com/qqflow/engine/domain/system/controller/SysNotificationConfigController.java
  61. 23 1
      src/main/java/com/qqflow/engine/domain/system/controller/SysRoleController.java
  62. 29 1
      src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java
  63. 18 0
      src/main/java/com/qqflow/engine/domain/system/dto/BindWeComDTO.java
  64. 20 0
      src/main/java/com/qqflow/engine/domain/system/dto/ChangePasswordDTO.java
  65. 12 0
      src/main/java/com/qqflow/engine/domain/system/dto/RoleDTO.java
  66. 8 0
      src/main/java/com/qqflow/engine/domain/system/dto/UserDTO.java
  67. 28 0
      src/main/java/com/qqflow/engine/domain/system/dto/WeComConfigDTO.java
  68. 47 0
      src/main/java/com/qqflow/engine/domain/system/entity/SysNotificationConfig.java
  69. 9 0
      src/main/java/com/qqflow/engine/domain/system/entity/SysRole.java
  70. 6 0
      src/main/java/com/qqflow/engine/domain/system/entity/SysUser.java
  71. 12 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysNotificationConfigMapper.java
  72. 16 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysRoleMapper.java
  73. 2 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java
  74. 4 2
      src/main/java/com/qqflow/engine/domain/system/service/RoleAuthService.java
  75. 8 0
      src/main/java/com/qqflow/engine/domain/system/service/SysDeptService.java
  76. 27 0
      src/main/java/com/qqflow/engine/domain/system/service/SysNotificationConfigService.java
  77. 2 0
      src/main/java/com/qqflow/engine/domain/system/service/SysRoleService.java
  78. 11 0
      src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java
  79. 41 1
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysDeptServiceImpl.java
  80. 105 0
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysNotificationConfigServiceImpl.java
  81. 83 25
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysRoleServiceImpl.java
  82. 118 23
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java
  83. 5 1
      src/main/resources/application-dev.yml
  84. 8 4
      src/main/resources/application.yml
  85. 9 9
      src/main/resources/data-dev.sql
  86. 30 0
      src/main/resources/data-mysql.sql
  87. 5 5
      src/main/resources/data-test.sql
  88. 225 0
      src/main/resources/mapper/flow/AnalysisMapper.xml
  89. 3 0
      src/main/resources/mapper/flow/ApprovalRecordMapper.xml
  90. 12 0
      src/main/resources/mapper/flow/ApprovalTaskMapper.xml
  91. 3 1
      src/main/resources/mapper/flow/ProcessDefinitionMapper.xml
  92. 12 1
      src/main/resources/mapper/flow/ProcessInstanceMapper.xml
  93. 74 9
      src/main/resources/schema-dev.sql
  94. 89 24
      src/main/resources/schema-mysql.sql
  95. 59 9
      src/main/resources/schema-test.sql

+ 5 - 6
README.md

@@ -9,8 +9,7 @@
 - Spring Security 6 + JWT
 - MyBatis-Plus 3.5.7
 - H2 Database (开发模式文件数据库)
-- Redis (embedded-redis 自动兜底)
-- Spring State Machine 4.0
+- Redis (embedded-redis 开发兜底)
 
 ## 环境要求
 
@@ -46,7 +45,7 @@ spring.datasource.url: jdbc:h2:file:./data/devdb;MODE=MySQL;AUTO_SERVER=TRUE
 
 - `spring.sql.init.mode: always` — 每次启动都会重新执行 `schema-dev.sql` 和 `data-dev.sql`
 - 如需保留手动修改的数据,可将 `mode` 改为 `never`
-- H2 Console 未开启,如需调试可直接连接 `jdbc:h2:file:./data/devdb`
+- H2 Console 在开发模式已开启,可通过 `http://localhost:8080/h2-console` 访问(生产请关闭)
 
 ### 默认账号
 
@@ -55,9 +54,9 @@ spring.datasource.url: jdbc:h2:file:./data/devdb;MODE=MySQL;AUTO_SERVER=TRUE
 | 账号 | 角色 |
 |------|------|
 | admin | 超级管理员 |
-| zhangsan | 普通用户 |
-| lisi | 普通用户 |
-| wangwu | 普通用户 |
+| zhangsan | 普通用户(技术部) |
+| lisi | 部门经理(财务部) |
+| wangwu | 流程管理员(人事部) |
 
 ### Redis
 

+ 10 - 16
pom.xml

@@ -19,7 +19,6 @@
         <mybatis-plus.version>3.5.7</mybatis-plus.version>
         <hutool.version>5.8.25</hutool.version>
         <jjwt.version>0.12.5</jjwt.version>
-        <spring-statemachine.version>4.0.0</spring-statemachine.version>
     </properties>
     <dependencies>
         <dependency>
@@ -39,11 +38,6 @@
             <artifactId>spring-boot-starter-data-redis</artifactId>
         </dependency>
         <dependency>
-            <groupId>org.springframework.statemachine</groupId>
-            <artifactId>spring-statemachine-starter</artifactId>
-            <version>${spring-statemachine.version}</version>
-        </dependency>
-        <dependency>
             <groupId>com.baomidou</groupId>
             <artifactId>mybatis-plus-boot-starter</artifactId>
             <version>${mybatis-plus.version}</version>
@@ -60,11 +54,6 @@
             <scope>runtime</scope>
         </dependency>
         <dependency>
-            <groupId>cn.hutool</groupId>
-            <artifactId>hutool-all</artifactId>
-            <version>${hutool.version}</version>
-        </dependency>
-        <dependency>
             <groupId>io.jsonwebtoken</groupId>
             <artifactId>jjwt-api</artifactId>
             <version>${jjwt.version}</version>
@@ -97,11 +86,6 @@
             <scope>test</scope>
         </dependency>
         <dependency>
-            <groupId>org.springframework.security</groupId>
-            <artifactId>spring-security-test</artifactId>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
             <groupId>com.h2database</groupId>
             <artifactId>h2</artifactId>
             <scope>runtime</scope>
@@ -111,6 +95,16 @@
             <artifactId>embedded-redis</artifactId>
             <version>1.4.3</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi</artifactId>
+            <version>5.2.5</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.poi</groupId>
+            <artifactId>poi-ooxml</artifactId>
+            <version>5.2.5</version>
+        </dependency>
     </dependencies>
     <build>
         <plugins>

+ 24 - 0
src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java

@@ -0,0 +1,24 @@
+package com.qqflow.engine.common.constant;
+
+/**
+ * 安全/权限相关常量
+ */
+public final class SecurityConstants {
+
+    private 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 ASSIGNEE_TYPE_ROLE = "ROLE";
+    public static final String ASSIGNEE_TYPE_USER = "USER";
+
+    public static final String ROLE_SUPER_ADMIN = "super_admin";
+    public static final String ROLE_FLOW_MANAGER = "flow_manager";
+    public static final String ROLE_DEPT_MANAGER = "dept_manager";
+
+    public static final String EMPLOYEE_TYPE_COMMON_USER = "common_user";
+}

+ 98 - 6
src/main/java/com/qqflow/engine/common/controller/FileUploadController.java

@@ -1,8 +1,16 @@
 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;
@@ -16,31 +24,74 @@ import java.nio.file.Paths;
 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(
+            "jpg", "jpeg", "png", "gif", "bmp", "webp",
+            "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
+            "txt", "md", "csv",
+            "zip", "rar", "7z",
+            "mp4", "mp3", "wav", "ogg"
+    );
+
+    private static final Set<String> BLOCKED_EXTENSIONS = Set.of(
+            "jsp", "jspx", "php", "asp", "aspx", "sh", "bat", "cmd", "exe", "dll", "jar", "war", "ear", "html", "htm", "js"
+    );
+
+    @Value("${file.upload.max-size:10485760}")
+    private long maxFileSize;
+
     @PostMapping("/upload")
     @Operation(summary = "上传文件")
     public Result<String> upload(@RequestParam("file") MultipartFile file) {
-        if (file.isEmpty()) {
+        if (file == null || file.isEmpty()) {
             return Result.error("文件不能为空");
         }
+        if (file.getSize() > maxFileSize) {
+            return Result.error("文件大小超过限制,最大允许 " + (maxFileSize / 1024 / 1024) + "MB");
+        }
+
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null || originalFilename.isBlank()) {
+            return Result.error("文件名不能为空");
+        }
+        // 防止路径穿越
+        if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\")) {
+            return Result.error("文件名包含非法字符");
+        }
+
+        String ext = extractExtension(originalFilename);
+        if (ext == null || ext.isBlank()) {
+            return Result.error("文件缺少扩展名,无法识别类型");
+        }
+        String extLower = ext.toLowerCase(Locale.ROOT);
+        if (BLOCKED_EXTENSIONS.contains(extLower)) {
+            return Result.error("禁止上传该类型文件:" + extLower);
+        }
+        if (!ALLOWED_EXTENSIONS.contains(extLower)) {
+            return Result.error("不允许上传该类型文件:" + extLower);
+        }
+
         try {
             Path uploadPath = Paths.get(UPLOAD_DIR, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM")));
             if (!Files.exists(uploadPath)) {
                 Files.createDirectories(uploadPath);
             }
-            String originalFilename = file.getOriginalFilename();
-            String ext = originalFilename != null && originalFilename.contains(".")
-                    ? originalFilename.substring(originalFilename.lastIndexOf("."))
-                    : "";
-            String filename = UUID.randomUUID() + ext;
+            String filename = UUID.randomUUID() + "." + extLower;
             Path targetPath = uploadPath.resolve(filename);
             Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
             String url = "/uploads/" + uploadPath.getFileName() + "/" + filename;
@@ -49,4 +100,45 @@ public class FileUploadController {
             return Result.error("文件上传失败: " + e.getMessage());
         }
     }
+
+    private String extractExtension(String filename) {
+        int lastDot = filename.lastIndexOf('.');
+        if (lastDot < 0 || lastDot == filename.length() - 1) {
+            return null;
+        }
+        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);
+        }
+    }
 }

+ 201 - 0
src/main/java/com/qqflow/engine/common/service/ExcelParseService.java

@@ -0,0 +1,201 @@
+package com.qqflow.engine.common.service;
+
+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.po.ProcessDefinition;
+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.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Service
+@RequiredArgsConstructor
+public class ExcelParseService {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private final ProcessDefinitionMapper processDefinitionMapper;
+
+    /**
+     * 根据字段映射关系解析 Excel,返回表单数据 Map。
+     *
+     * @param file         Excel 文件
+     * @param definitionId 流程定义 ID
+     * @param mappings     Excel 列名 -> 系统字段名 的映射
+     */
+    public Map<String, Object> parseExcel(MultipartFile file, Long definitionId, Map<String, String> mappings) {
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException("文件不能为空");
+        }
+        if (mappings == null || mappings.isEmpty()) {
+            throw new BusinessException("字段映射不能为空");
+        }
+        ProcessDefinition definition = this.processDefinitionMapper.selectById(definitionId);
+        if (definition == null) {
+            throw new BusinessException("流程定义不存在");
+        }
+
+        try (InputStream is = file.getInputStream();
+             Workbook workbook = createWorkbook(is, file.getOriginalFilename())) {
+            Sheet sheet = workbook.getSheetAt(0);
+            if (sheet.getPhysicalNumberOfRows() < 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());
+                }
+            }
+
+            // 读取第一行数据
+            Row dataRow = sheet.getRow(1);
+            if (dataRow == null) {
+                throw new BusinessException("Excel 数据行为空");
+            }
+
+            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;
+                }
+                Cell cell = dataRow.getCell(entry.getKey());
+                Object value = getCellValue(cell);
+                formData.put(systemField, value);
+            }
+            return formData;
+        } catch (IOException e) {
+            throw new BusinessException("Excel 解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 生成 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("示例值");
+            }
+            // 自动调整列宽
+            for (int i = 0; i < fields.size(); i++) {
+                sheet.autoSizeColumn(i);
+            }
+
+            try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+                workbook.write(bos);
+                return bos.toByteArray();
+            }
+        } catch (IOException e) {
+            throw new BusinessException("模板生成失败: " + e.getMessage());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> parseFormSchema(String formSchema) {
+        if (formSchema == null || formSchema.isBlank()) {
+            return Collections.emptyList();
+        }
+        try {
+            return OBJECT_MAPPER.readValue(formSchema, List.class);
+        } catch (Exception e) {
+            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 -> "";
+        };
+    }
+}

+ 35 - 0
src/main/java/com/qqflow/engine/common/util/JwtUtils.java

@@ -2,8 +2,11 @@ package com.qqflow.engine.common.util;
 
 import com.qqflow.engine.config.security.LoginUser;
 import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 
@@ -13,12 +16,22 @@ import java.util.Date;
 import java.util.List;
 import java.util.UUID;
 
+@Slf4j
 @Component
 public class JwtUtils {
 
+    private static final String 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");
+        }
+    }
+
     @Value("${jwt.expiration}")
     private Long expiration;
 
@@ -61,6 +74,20 @@ public class JwtUtils {
         return parseToken(token).getSubject();
     }
 
+    public Date getExpirationDate(String token) {
+        return parseToken(token).getExpiration();
+    }
+
+    public Date getIssuedAtDate(String token) {
+        return parseToken(token).getIssuedAt();
+    }
+
+    public long getRemainingTime(String token) {
+        Date expirationDate = getExpirationDate(token);
+        long remaining = expirationDate.getTime() - System.currentTimeMillis();
+        return Math.max(remaining, 0);
+    }
+
     @SuppressWarnings("unchecked")
     public LoginUser parseLoginUser(String token) {
         Claims claims = parseToken(token);
@@ -75,4 +102,12 @@ public class JwtUtils {
         user.setRoles(roles != null ? roles : null);
         return user;
     }
+
+    public boolean isTokenExpired(String token) {
+        try {
+            return getExpirationDate(token).before(new Date());
+        } catch (ExpiredJwtException e) {
+            return true;
+        }
+    }
 }

+ 2 - 0
src/main/java/com/qqflow/engine/config/EmbeddedRedisConfig.java

@@ -4,6 +4,7 @@ import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
 import redis.embedded.RedisServer;
 
 import java.io.IOException;
@@ -11,6 +12,7 @@ import java.net.Socket;
 
 @Slf4j
 @Configuration
+@Profile("dev")
 public class EmbeddedRedisConfig {
 
     private RedisServer redisServer;

+ 2 - 0
src/main/java/com/qqflow/engine/config/MyBatisPlusConfig.java

@@ -2,6 +2,7 @@ package com.qqflow.engine.config;
 
 import com.baomidou.mybatisplus.annotation.DbType;
 import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
 import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -13,6 +14,7 @@ public class MyBatisPlusConfig {
     public MybatisPlusInterceptor mybatisPlusInterceptor() {
         MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
         interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor(true));
         return interceptor;
     }
 }

+ 2 - 0
src/main/java/com/qqflow/engine/config/RedisConfig.java

@@ -19,6 +19,8 @@ public class RedisConfig {
         template.setConnectionFactory(connectionFactory);
 
         ObjectMapper mapper = new ObjectMapper();
+        mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
+        mapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
         mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
 
         GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);

+ 17 - 1
src/main/java/com/qqflow/engine/config/WebConfig.java

@@ -1,17 +1,33 @@
 package com.qqflow.engine.config;
 
+import org.springframework.beans.factory.annotation.Value;
 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;
+
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
 
+    @Value("${cors.allowed-origins:http://localhost:3000,http://127.0.0.1:3000}")
+    private String allowedOrigins;
+
     @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("*")
+                .allowedOriginPatterns(originArray)
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)

+ 50 - 5
src/main/java/com/qqflow/engine/config/security/JwtAuthenticationFilter.java

@@ -1,20 +1,29 @@
 package com.qqflow.engine.config.security;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.qqflow.engine.common.Result;
 import com.qqflow.engine.common.util.JwtUtils;
+import com.qqflow.engine.common.util.RedisCache;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.security.SignatureException;
 import jakarta.annotation.Resource;
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
-import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 
 @Slf4j
 @Component
@@ -23,14 +32,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
     @Resource
     private JwtUtils jwtUtils;
 
+    @Resource
+    private RedisCache redisCache;
+
     @Override
     protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
-        String token = request.getHeader("Authorization");
-        if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
-            token = token.substring(7);
+        String header = request.getHeader("Authorization");
+        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
+            String token = header.substring(7);
             try {
+                if (isBlacklisted(token)) {
+                    writeUnauthorized(response, "Token 已失效,请重新登录");
+                    return;
+                }
+
                 LoginUser loginUser = jwtUtils.parseLoginUser(token);
                 if (loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                     UsernamePasswordAuthenticationToken authentication =
@@ -41,10 +58,38 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                     authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                     SecurityContextHolder.getContext().setAuthentication(authentication);
                 }
+            } catch (ExpiredJwtException e) {
+                log.debug("JWT token 已过期: {}", e.getMessage());
+                writeUnauthorized(response, "登录已过期,请重新登录");
+                return;
+            } catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
+                log.debug("JWT token 非法: {}", e.getMessage());
+                writeUnauthorized(response, "Token 非法,请重新登录");
+                return;
             } catch (Exception e) {
-                log.debug("JWT token解析失败: {}", e.getMessage());
+                log.debug("JWT token 解析失败: {}", e.getMessage());
+                writeUnauthorized(response, "Token 解析失败,请重新登录");
+                return;
             }
         }
         chain.doFilter(request, response);
     }
+
+    private boolean isBlacklisted(String token) {
+        try {
+            String uuid = jwtUtils.getUuidFromToken(token);
+            Object value = redisCache.getCacheObject("token:blacklist:" + uuid);
+            return value != null;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        Result<Void> result = Result.error(401, message);
+        response.getWriter().write(new ObjectMapper().writeValueAsString(result));
+    }
 }

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

@@ -10,6 +10,8 @@ import java.util.Collection;
 import java.util.List;
 import java.util.stream.Collectors;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.ROLE_SUPER_ADMIN;
+
 @Data
 public class LoginUser implements UserDetails {
 
@@ -57,6 +59,6 @@ public class LoginUser implements UserDetails {
     }
 
     public boolean isAdmin() {
-        return roles != null && roles.contains("super_admin");
+        return roles != null && roles.contains(ROLE_SUPER_ADMIN);
     }
 }

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

@@ -30,6 +30,7 @@ public class SecurityConfig {
                 .authorizeHttpRequests(auth -> auth
                         .requestMatchers("/auth/**").permitAll()
                         .requestMatchers("/webhook/**").permitAll()
+                        .requestMatchers("/uploads/**").permitAll()
                         .requestMatchers(HttpMethod.OPTIONS).permitAll()
                         .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                         .anyRequest().authenticated()

+ 1 - 12
src/main/java/com/qqflow/engine/domain/flow/assembler/ApprovalTaskAssembler.java

@@ -3,8 +3,6 @@ package com.qqflow.engine.domain.flow.assembler;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import org.springframework.stereotype.Component;
 
-import java.time.LocalDateTime;
-
 @Component
 public class ApprovalTaskAssembler {
 
@@ -19,16 +17,7 @@ public class ApprovalTaskAssembler {
         po.setAssigneeId(assigneeId);
         po.setAssigneeType(assigneeType);
         po.setTaskStatus(taskStatus);
-        return po;
-    }
-
-    public ApprovalTask buildTransferTask(Long instanceId, String nodeId, String nodeName,
-                                           String nodeType, Long assigneeId,
-                                           String assigneeType, Integer taskStatus,
-                                           LocalDateTime timeoutTime, String timeoutAction) {
-        ApprovalTask po = buildNew(instanceId, nodeId, nodeName, nodeType, assigneeId, assigneeType, taskStatus);
-        po.setTimeoutTime(timeoutTime);
-        po.setTimeoutAction(timeoutAction);
+        po.setVersion(0);
         return po;
     }
 }

+ 69 - 0
src/main/java/com/qqflow/engine/domain/flow/assembler/ApprovalTaskDTOAssembler.java

@@ -0,0 +1,69 @@
+package com.qqflow.engine.domain.flow.assembler;
+
+import com.qqflow.engine.domain.flow.dto.ApprovalTaskDTO;
+import com.qqflow.engine.domain.system.entity.SysRole;
+import com.qqflow.engine.domain.system.entity.SysUser;
+import com.qqflow.engine.domain.system.mapper.SysRoleMapper;
+import com.qqflow.engine.domain.system.mapper.SysUserMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_ROLE;
+
+/**
+ * 审批任务 DTO 组装器
+ */
+@Component
+@RequiredArgsConstructor
+public class ApprovalTaskDTOAssembler {
+
+    private final SysUserMapper sysUserMapper;
+    private final SysRoleMapper sysRoleMapper;
+
+    public void fillAssigneeName(List<ApprovalTaskDTO> records) {
+        if (CollectionUtils.isEmpty(records)) {
+            return;
+        }
+        Set<Long> userIds = records.stream()
+                .filter(r -> !ASSIGNEE_TYPE_ROLE.equals(r.getAssigneeType()) && r.getAssigneeId() != null)
+                .map(ApprovalTaskDTO::getAssigneeId)
+                .collect(Collectors.toSet());
+        Set<Long> roleIds = records.stream()
+                .filter(r -> ASSIGNEE_TYPE_ROLE.equals(r.getAssigneeType()) && r.getAssigneeId() != null)
+                .map(ApprovalTaskDTO::getAssigneeId)
+                .collect(Collectors.toSet());
+
+        Map<Long, String> userNameMap = new HashMap<>();
+        Map<Long, String> roleNameMap = new HashMap<>();
+        if (!CollectionUtils.isEmpty(userIds)) {
+            List<SysUser> users = this.sysUserMapper.selectBatchIds(userIds);
+            if (!CollectionUtils.isEmpty(users)) {
+                users.forEach(u -> userNameMap.put(u.getId(), u.getRealName()));
+            }
+        }
+        if (!CollectionUtils.isEmpty(roleIds)) {
+            List<SysRole> roles = this.sysRoleMapper.selectBatchIds(roleIds);
+            if (!CollectionUtils.isEmpty(roles)) {
+                roles.forEach(r -> roleNameMap.put(r.getId(), r.getRoleName()));
+            }
+        }
+
+        for (ApprovalTaskDTO record : records) {
+            if (record.getAssigneeId() == null) {
+                continue;
+            }
+            if (ASSIGNEE_TYPE_ROLE.equals(record.getAssigneeType())) {
+                record.setAssigneeName(roleNameMap.get(record.getAssigneeId()));
+            } else {
+                record.setAssigneeName(userNameMap.get(record.getAssigneeId()));
+            }
+        }
+    }
+}

+ 2 - 0
src/main/java/com/qqflow/engine/domain/flow/assembler/ProcessDefinitionAssembler.java

@@ -12,6 +12,7 @@ public class ProcessDefinitionAssembler {
         po.setProcessName(dto.getProcessName());
         po.setCategory(dto.getCategory());
         po.setFormId(dto.getFormId());
+        po.setFormSchema(dto.getFormSchema());
         po.setModelJson(dto.getModelJson());
         po.setVersion(1);
         po.setStatus(0);
@@ -27,6 +28,7 @@ public class ProcessDefinitionAssembler {
         po.setProcessName(dto.getProcessName());
         po.setCategory(dto.getCategory());
         po.setFormId(dto.getFormId());
+        po.setFormSchema(dto.getFormSchema());
         po.setModelJson(dto.getModelJson());
         po.setDescription(dto.getDescription());
         return po;

+ 88 - 0
src/main/java/com/qqflow/engine/domain/flow/controller/AnalysisController.java

@@ -0,0 +1,88 @@
+package com.qqflow.engine.domain.flow.controller;
+
+import com.qqflow.engine.common.PageResult;
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.domain.flow.dto.analysis.AnalysisOverviewDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.NodeStayStatDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.ProcessEfficiencyDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StuckInstanceDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StatusDistributionDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.TrendDTO;
+import com.qqflow.engine.domain.flow.service.AnalysisService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 流程数据分析看板
+ */
+@RestController
+@RequestMapping("/analysis")
+@RequiredArgsConstructor
+@Tag(name = "流程数据分析")
+@PreAuthorize("hasAnyRole('super_admin','flow_manager')")
+public class AnalysisController {
+
+    private final AnalysisService analysisService;
+
+    @GetMapping("/overview")
+    @Operation(summary = "数据分析看板概览 KPI")
+    public Result<AnalysisOverviewDTO> overview(
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @RequestParam(required = false) Long processDefinitionId) {
+        return Result.ok(analysisService.overview(startTime, endTime, processDefinitionId));
+    }
+
+    @GetMapping("/status-distribution")
+    @Operation(summary = "流程状态分布")
+    public Result<List<StatusDistributionDTO>> statusDistribution(
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @RequestParam(required = false) Long processDefinitionId) {
+        return Result.ok(analysisService.statusDistribution(startTime, endTime, processDefinitionId));
+    }
+
+    @GetMapping("/trend")
+    @Operation(summary = "近30天流程趋势")
+    public Result<List<TrendDTO>> trend(
+            @RequestParam(required = false) Long processDefinitionId) {
+        return Result.ok(analysisService.trend(processDefinitionId));
+    }
+
+    @GetMapping("/completed-efficiency")
+    @Operation(summary = "已完成流程效率统计")
+    public Result<List<ProcessEfficiencyDTO>> completedEfficiency(
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @RequestParam(required = false) Long processDefinitionId) {
+        return Result.ok(analysisService.completedEfficiency(startTime, endTime, processDefinitionId));
+    }
+
+    @GetMapping("/in-progress-by-node")
+    @Operation(summary = "进行中流程节点停留统计")
+    public Result<List<NodeStayStatDTO>> inProgressByNode(
+            @RequestParam(required = false) Long processDefinitionId) {
+        return Result.ok(analysisService.inProgressByNode(processDefinitionId));
+    }
+
+    @GetMapping("/stuck-instances")
+    @Operation(summary = "节点下钻明细(卡住流程实例)")
+    public Result<PageResult<StuckInstanceDTO>> stuckInstances(
+            @RequestParam(required = false) String nodeId,
+            @RequestParam(required = false) Long processDefinitionId,
+            @RequestParam(required = false) Long minStayMinutes,
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize) {
+        return Result.ok(analysisService.stuckInstances(nodeId, processDefinitionId, minStayMinutes, pageNum, pageSize));
+    }
+}

+ 30 - 4
src/main/java/com/qqflow/engine/domain/flow/controller/ApprovalTaskController.java

@@ -2,10 +2,13 @@ package com.qqflow.engine.domain.flow.controller;
 
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.Result;
+import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.common.util.SecurityUtils;
+import com.qqflow.engine.config.security.LoginUser;
 import com.qqflow.engine.domain.flow.dto.ApprovalRecordDTO;
 import com.qqflow.engine.domain.flow.dto.ApprovalTaskDTO;
 import com.qqflow.engine.domain.flow.dto.ApproveTaskDTO;
+import com.qqflow.engine.domain.flow.dto.BatchTaskDTO;
 import com.qqflow.engine.domain.flow.dto.TransferTaskDTO;
 import com.qqflow.engine.domain.flow.service.ApprovalTaskService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -37,7 +40,7 @@ public class ApprovalTaskController {
             @RequestParam(defaultValue = "10") Integer pageSize,
             @RequestParam(required = false) String processName) {
         Long assigneeId = SecurityUtils.getUserId();
-        String assigneeType = SecurityUtils.getLoginUser().getUserType();
+        String assigneeType = requireLoginUser().getUserType();
         return Result.ok(this.approvalTaskService.todoList(assigneeId, assigneeType, processName, pageNum, pageSize));
     }
 
@@ -48,7 +51,7 @@ public class ApprovalTaskController {
             @RequestParam(defaultValue = "10") Integer pageSize,
             @RequestParam(required = false) String processName) {
         Long assigneeId = SecurityUtils.getUserId();
-        String assigneeType = SecurityUtils.getLoginUser().getUserType();
+        String assigneeType = requireLoginUser().getUserType();
         return Result.ok(this.approvalTaskService.handledList(assigneeId, assigneeType, processName, pageNum, pageSize));
     }
 
@@ -68,6 +71,20 @@ public class ApprovalTaskController {
         return Result.ok();
     }
 
+    @PostMapping("/batch-approve")
+    @Operation(summary = "批量审批通过")
+    public Result<Void> batchApprove(@RequestBody @Valid BatchTaskDTO dto) {
+        this.approvalTaskService.batchApprove(dto);
+        return Result.ok();
+    }
+
+    @PostMapping("/batch-reject")
+    @Operation(summary = "批量审批拒绝")
+    public Result<Void> batchReject(@RequestBody @Valid BatchTaskDTO dto) {
+        this.approvalTaskService.batchReject(dto);
+        return Result.ok();
+    }
+
     @PostMapping("/{taskId}/return")
     @Operation(summary = "审批回退")
     public Result<Void> returnTask(@PathVariable Long taskId, @RequestBody @Valid ApproveTaskDTO dto) {
@@ -101,7 +118,7 @@ public class ApprovalTaskController {
     @Operation(summary = "我的待办任务数")
     public Result<Long> todoCount() {
         Long assigneeId = SecurityUtils.getUserId();
-        String assigneeType = SecurityUtils.getLoginUser().getUserType();
+        String assigneeType = requireLoginUser().getUserType();
         return Result.ok(this.approvalTaskService.todoCount(assigneeId, assigneeType));
     }
 
@@ -112,7 +129,8 @@ public class ApprovalTaskController {
             @RequestParam(defaultValue = "10") Integer pageSize,
             @RequestParam(required = false) String processName) {
         Long assigneeId = SecurityUtils.getUserId();
-        return Result.ok(this.approvalTaskService.ccList(assigneeId, processName, pageNum, pageSize));
+        String assigneeType = requireLoginUser().getUserType();
+        return Result.ok(this.approvalTaskService.ccList(assigneeId, assigneeType, processName, pageNum, pageSize));
     }
 
     @PostMapping("/cc/{taskId}/read")
@@ -121,4 +139,12 @@ public class ApprovalTaskController {
         this.approvalTaskService.readCc(taskId);
         return Result.ok();
     }
+
+    private LoginUser requireLoginUser() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null) {
+            throw new BusinessException("未登录");
+        }
+        return loginUser;
+    }
 }

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/controller/ProcessDefinitionController.java

@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -27,6 +28,7 @@ import java.util.List;
 @RequestMapping("/flow/definition")
 @RequiredArgsConstructor
 @Tag(name = "流程定义管理")
+@PreAuthorize("hasAnyRole('super_admin','flow_manager')")
 public class ProcessDefinitionController {
 
     private final ProcessDefinitionService processDefinitionService;
@@ -92,6 +94,7 @@ public class ProcessDefinitionController {
 
     @GetMapping("/list-enabled")
     @Operation(summary = "启用中的流程定义列表")
+    @PreAuthorize("isAuthenticated()")
     public Result<List<ProcessDefinitionDTO>> listEnabled() {
         return Result.ok(this.processDefinitionService.listEnabled());
     }

+ 32 - 3
src/main/java/com/qqflow/engine/domain/flow/controller/ProcessInstanceController.java

@@ -2,7 +2,10 @@ package com.qqflow.engine.domain.flow.controller;
 
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.Result;
+import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.common.util.SecurityUtils;
+import com.qqflow.engine.config.security.LoginUser;
+import com.qqflow.engine.domain.flow.dto.AttachmentDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessInstanceDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessProgressDTO;
 import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
@@ -11,6 +14,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -19,6 +23,8 @@ 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;
+
 @RestController
 @RequestMapping("/flow/instance")
 @RequiredArgsConstructor
@@ -47,9 +53,10 @@ public class ProcessInstanceController {
     @Operation(summary = "我的流程列表")
     public Result<PageResult<ProcessInstanceDTO>> mine(
             @RequestParam(defaultValue = "1") Integer pageNum,
-            @RequestParam(defaultValue = "10") Integer pageSize) {
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam(required = false) Integer status) {
         Long applicantId = SecurityUtils.getUserId();
-        return Result.ok(this.processInstanceService.list(applicantId, null, pageNum, pageSize));
+        return Result.ok(this.processInstanceService.list(applicantId, status, pageNum, pageSize));
     }
 
     @GetMapping("/{id}")
@@ -65,18 +72,40 @@ public class ProcessInstanceController {
         return Result.ok();
     }
 
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除流程实例")
+    public Result<Void> delete(@PathVariable Long id) {
+        this.processInstanceService.delete(id);
+        return Result.ok();
+    }
+
     @GetMapping("/{id}/progress")
     @Operation(summary = "流程实例进度")
     public Result<ProcessProgressDTO> progress(@PathVariable Long id) {
         return Result.ok(this.processInstanceService.getProgress(id));
     }
 
+    @GetMapping("/{id}/attachments")
+    @Operation(summary = "流程实例附件列表")
+    public Result<List<AttachmentDTO>> attachments(@PathVariable Long id) {
+        return Result.ok(this.processInstanceService.listAttachments(id));
+    }
+
     @GetMapping("/participated")
     @Operation(summary = "我参与的流程实例列表")
     public Result<PageResult<ProcessInstanceDTO>> participated(
             @RequestParam(defaultValue = "1") Integer pageNum,
             @RequestParam(defaultValue = "10") Integer pageSize) {
         Long userId = SecurityUtils.getUserId();
-        return Result.ok(this.processInstanceService.participatedList(userId, pageNum, pageSize));
+        String userType = requireLoginUser().getUserType();
+        return Result.ok(this.processInstanceService.participatedList(userId, userType, pageNum, pageSize));
+    }
+
+    private LoginUser requireLoginUser() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null) {
+            throw new BusinessException("未登录");
+        }
+        return loginUser;
     }
 }

+ 13 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/ApprovalTaskDTO.java

@@ -31,9 +31,15 @@ public class ApprovalTaskDTO {
     @Schema(description = "处理人类型")
     private String assigneeType;
 
+    @Schema(description = "处理人名称")
+    private String assigneeName;
+
     @Schema(description = "任务状态")
     private Integer taskStatus;
 
+    @Schema(description = "任务状态(taskStatus别名)")
+    private Integer status;
+
     @Schema(description = "审批结果")
     private String approvalResult;
 
@@ -52,6 +58,12 @@ public class ApprovalTaskDTO {
     @Schema(description = "超时时间")
     private LocalDateTime timeoutTime;
 
+    @Schema(description = "紧急度:0-正常,1-即将超时,2-已超时")
+    private Integer urgency;
+
+    @Schema(description = "剩余分钟数(超时后为负值)")
+    private Long remainingMinutes;
+
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 
@@ -83,6 +95,7 @@ public class ApprovalTaskDTO {
         dto.setAssigneeId(po.getAssigneeId());
         dto.setAssigneeType(po.getAssigneeType());
         dto.setTaskStatus(po.getTaskStatus());
+        dto.setStatus(po.getTaskStatus());
         dto.setApprovalResult(po.getApprovalResult());
         dto.setApprovalComment(po.getApprovalComment());
         dto.setAttachmentUrls(po.getAttachmentUrls());

+ 72 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/AttachmentDTO.java

@@ -0,0 +1,72 @@
+package com.qqflow.engine.domain.flow.dto;
+
+import com.qqflow.engine.domain.flow.po.Attachment;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Schema(description = "流程附件DTO")
+public class AttachmentDTO {
+
+    @Schema(description = "附件ID")
+    private Long id;
+
+    @Schema(description = "流程实例ID")
+    private Long instanceId;
+
+    @Schema(description = "关联任务ID")
+    private Long taskId;
+
+    @Schema(description = "关联审批记录ID")
+    private Long recordId;
+
+    @Schema(description = "节点ID")
+    private String nodeId;
+
+    @Schema(description = "节点名称")
+    private String nodeName;
+
+    @Schema(description = "原始文件名")
+    private String fileName;
+
+    @Schema(description = "文件访问URL")
+    private String fileUrl;
+
+    @Schema(description = "文件大小(字节)")
+    private Long fileSize;
+
+    @Schema(description = "上传人ID")
+    private Long uploaderId;
+
+    @Schema(description = "上传人姓名")
+    private String uploaderName;
+
+    @Schema(description = "上传人类型")
+    private String uploaderType;
+
+    @Schema(description = "上传时间")
+    private LocalDateTime createTime;
+
+    public static AttachmentDTO of(Attachment po) {
+        if (po == null) {
+            return null;
+        }
+        AttachmentDTO dto = new AttachmentDTO();
+        dto.setId(po.getId());
+        dto.setInstanceId(po.getInstanceId());
+        dto.setTaskId(po.getTaskId());
+        dto.setRecordId(po.getRecordId());
+        dto.setNodeId(po.getNodeId());
+        dto.setNodeName(po.getNodeName());
+        dto.setFileName(po.getFileName());
+        dto.setFileUrl(po.getFileUrl());
+        dto.setFileSize(po.getFileSize());
+        dto.setUploaderId(po.getUploaderId());
+        dto.setUploaderName(po.getUploaderName());
+        dto.setUploaderType(po.getUploaderType());
+        dto.setCreateTime(po.getCreateTime());
+        return dto;
+    }
+}

+ 22 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/BatchTaskDTO.java

@@ -0,0 +1,22 @@
+package com.qqflow.engine.domain.flow.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(description = "批量审批任务DTO")
+public class BatchTaskDTO {
+
+    @NotEmpty(message = "任务ID列表不能为空")
+    @Schema(description = "任务ID列表")
+    private List<Long> taskIds;
+
+    @Schema(description = "审批意见")
+    private String comment;
+
+    @Schema(description = "附件URL")
+    private String attachmentUrls;
+}

+ 5 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/ProcessDefinitionDTO.java

@@ -32,6 +32,10 @@ public class ProcessDefinitionDTO {
     @Schema(description = "表单ID")
     private Long formId;
 
+    @JsonProperty("formSchema")
+    @Schema(description = "表单字段定义JSON")
+    private String formSchema;
+
     @JsonProperty("flowJson")
     @Schema(description = "模型JSON")
     private String modelJson;
@@ -64,6 +68,7 @@ public class ProcessDefinitionDTO {
         dto.setProcessName(po.getProcessName());
         dto.setCategory(po.getCategory());
         dto.setFormId(po.getFormId());
+        dto.setFormSchema(po.getFormSchema());
         dto.setModelJson(po.getModelJson());
         dto.setVersion(po.getVersion());
         dto.setStatus(po.getStatus());

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/ProcessInstanceDTO.java

@@ -46,6 +46,9 @@ public class ProcessInstanceDTO {
     @Schema(description = "当前节点(currentNodeId别名)")
     private String currentNode;
 
+    @Schema(description = "当前节点名称")
+    private String currentNodeName;
+
     @Schema(description = "状态")
     private Integer status;
 

+ 36 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/AnalysisOverviewDTO.java

@@ -0,0 +1,36 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 数据分析看板概览 KPI DTO
+ */
+@Data
+@Schema(description = "数据分析看板概览 KPI")
+public class AnalysisOverviewDTO {
+
+    @Schema(description = "流程总数")
+    private Long totalInstances;
+
+    @Schema(description = "已完成数")
+    private Long completedCount;
+
+    @Schema(description = "进行中数")
+    private Long runningCount;
+
+    @Schema(description = "已拒绝数")
+    private Long rejectedCount;
+
+    @Schema(description = "已撤回数")
+    private Long revokedCount;
+
+    @Schema(description = "超时任务数")
+    private Long timeoutCount;
+
+    @Schema(description = "超时率(%)")
+    private Double timeoutRate;
+
+    @Schema(description = "平均耗时(分钟)")
+    private Long avgDurationMinutes;
+}

+ 33 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/NodeStayStatDTO.java

@@ -0,0 +1,33 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 节点停留统计 DTO
+ */
+@Data
+@Schema(description = "节点停留统计")
+public class NodeStayStatDTO {
+
+    @Schema(description = "节点ID")
+    private String nodeId;
+
+    @Schema(description = "节点名称")
+    private String nodeName;
+
+    @Schema(description = "流程定义ID")
+    private Long processDefinitionId;
+
+    @Schema(description = "流程名称")
+    private String processName;
+
+    @Schema(description = "任务数/停留数")
+    private Long taskCount;
+
+    @Schema(description = "平均停留时长(分钟)")
+    private Long avgStayMinutes;
+
+    @Schema(description = "最大停留时长(分钟)")
+    private Long maxStayMinutes;
+}

+ 35 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/ProcessEfficiencyDTO.java

@@ -0,0 +1,35 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 流程效率统计 DTO
+ */
+@Data
+@Schema(description = "流程效率统计")
+public class ProcessEfficiencyDTO {
+
+    @Schema(description = "流程定义ID")
+    private Long processDefinitionId;
+
+    @Schema(description = "流程名称")
+    private String processName;
+
+    @Schema(description = "已完成实例数")
+    private Long instanceCount;
+
+    @Schema(description = "平均耗时(分钟)")
+    private Long avgDurationMinutes;
+
+    @Schema(description = "最大耗时(分钟)")
+    private Long maxDurationMinutes;
+
+    @Schema(description = "最小耗时(分钟)")
+    private Long minDurationMinutes;
+
+    @Schema(description = "各节点停留统计")
+    private List<NodeStayStatDTO> nodeStats;
+}

+ 21 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/StatusDistributionDTO.java

@@ -0,0 +1,21 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 流程状态分布 DTO
+ */
+@Data
+@Schema(description = "流程状态分布")
+public class StatusDistributionDTO {
+
+    @Schema(description = "状态码")
+    private Integer status;
+
+    @Schema(description = "状态名称")
+    private String statusName;
+
+    @Schema(description = "实例数")
+    private Long count;
+}

+ 47 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/StuckInstanceDTO.java

@@ -0,0 +1,47 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 卡住流程实例明细 DTO
+ */
+@Data
+@Schema(description = "卡住流程实例明细")
+public class StuckInstanceDTO {
+
+    @Schema(description = "实例ID")
+    private Long instanceId;
+
+    @Schema(description = "实例编号")
+    private String instanceNo;
+
+    @Schema(description = "实例标题")
+    private String title;
+
+    @Schema(description = "流程定义ID")
+    private Long processDefinitionId;
+
+    @Schema(description = "流程名称")
+    private String processName;
+
+    @Schema(description = "申请人ID")
+    private Long applicantId;
+
+    @Schema(description = "申请人姓名")
+    private String applicantName;
+
+    @Schema(description = "当前节点ID")
+    private String nodeId;
+
+    @Schema(description = "当前节点名称")
+    private String nodeName;
+
+    @Schema(description = "任务到达时间")
+    private LocalDateTime taskCreateTime;
+
+    @Schema(description = "已停留分钟数")
+    private Long stayMinutes;
+}

+ 24 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/analysis/TrendDTO.java

@@ -0,0 +1,24 @@
+package com.qqflow.engine.domain.flow.dto.analysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 流程趋势 DTO
+ */
+@Data
+@Schema(description = "流程趋势")
+public class TrendDTO {
+
+    @Schema(description = "日期(yyyy-MM-dd)")
+    private String date;
+
+    @Schema(description = "发起数")
+    private Long startedCount;
+
+    @Schema(description = "完成数")
+    private Long completedCount;
+
+    @Schema(description = "拒绝数")
+    private Long rejectedCount;
+}

+ 1 - 3
src/main/java/com/qqflow/engine/domain/flow/enums/ApprovalAction.java

@@ -11,9 +11,7 @@ public enum ApprovalAction {
     REJECT("REJECT", "拒绝"),
     RETURN("RETURN", "回退"),
     TRANSFER("TRANSFER", "转办"),
-    DELEGATE("DELEGATE", "委派"),
-    ADD_SIGN("ADD_SIGN", "加签"),
-    REVOKE("REVOKE", "撤回");
+    ADD_SIGN("ADD_SIGN", "加签");
 
     private final String code;
     private final String desc;

+ 2 - 1
src/main/java/com/qqflow/engine/domain/flow/enums/ApprovalResult.java

@@ -10,7 +10,8 @@ public enum ApprovalResult {
     PASS("PASS", "通过"),
     REJECT("REJECT", "拒绝"),
     RETURN("RETURN", "回退"),
-    TRANSFER("TRANSFER", "转办");
+    TRANSFER("TRANSFER", "转办"),
+    REVOKE("REVOKE", "撤回");
 
     private final String code;
     private final String desc;

+ 0 - 1
src/main/java/com/qqflow/engine/domain/flow/enums/ProcessStatus.java

@@ -9,7 +9,6 @@ public enum ProcessStatus {
 
     PENDING_RECEIVE(0, "待接收"),
     PENDING(1, "待处理"),
-    APPROVED(2, "已通过"),
     REJECTED(3, "已拒绝"),
     RETURNED(4, "已回退"),
     COMPLETED(5, "整体完成"),

+ 4 - 1
src/main/java/com/qqflow/engine/domain/flow/event/TaskAssignedEvent.java

@@ -16,14 +16,17 @@ public class TaskAssignedEvent extends ApplicationEvent {
     private final String processName;
     private final String nodeName;
     private final List<Long> assigneeIds;
+    private final String assigneeType;
 
     public TaskAssignedEvent(Object source, Long instanceId, String instanceTitle,
-                             String processName, String nodeName, List<Long> assigneeIds) {
+                             String processName, String nodeName, List<Long> assigneeIds,
+                             String assigneeType) {
         super(source);
         this.instanceId = instanceId;
         this.instanceTitle = instanceTitle;
         this.processName = processName;
         this.nodeName = nodeName;
         this.assigneeIds = assigneeIds;
+        this.assigneeType = assigneeType;
     }
 }

+ 86 - 0
src/main/java/com/qqflow/engine/domain/flow/job/OverdueCheckJob.java

@@ -0,0 +1,86 @@
+package com.qqflow.engine.domain.flow.job;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.qqflow.engine.domain.flow.enums.TaskStatus;
+import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import com.qqflow.engine.domain.flow.service.NotificationService;
+import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
+import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
+import com.qqflow.engine.domain.flow.po.ProcessInstance;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 审批任务超时检查定时任务
+ * <p>
+ * 每 5 分钟扫描一次待处理任务:
+ * 1. 对已超时的任务发送催办通知(按节点配置的 timeout_action,目前仅实现 remind,pass/reject 预留)。
+ * 2. 对 24 小时内即将超时的任务发送预警提醒。
+ * <p>
+ * 生产环境使用 MySQL,H2 开发环境同样兼容。
+ * 注意:实际的消息推送由 NotificationService 的实现类(如企微对接预留服务)处理,本任务只负责扫描和触发。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@Profile("!test")
+public class OverdueCheckJob {
+
+    private final ApprovalTaskMapper approvalTaskMapper;
+    private final ProcessInstanceMapper processInstanceMapper;
+    private final NotificationService notificationService;
+
+    /**
+     * 每 5 分钟执行一次
+     */
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void check() {
+        LocalDateTime now = LocalDateTime.now();
+        LocalDateTime warningThreshold = now.plusHours(24);
+
+        // 1. 已超时任务
+        List<ApprovalTask> overdueTasks = this.approvalTaskMapper.selectList(
+                Wrappers.<ApprovalTask>lambdaQuery()
+                        .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                        .isNotNull(ApprovalTask::getTimeoutTime)
+                        .le(ApprovalTask::getTimeoutTime, now)
+        );
+        for (ApprovalTask task : overdueTasks) {
+            this.sendReminder(task, true);
+        }
+        if (!overdueTasks.isEmpty()) {
+            log.info("[超时检查] 发现 {} 条已超时待处理任务", overdueTasks.size());
+        }
+
+        // 2. 即将超时(24h 内)任务,仅提醒一次:取 timeoutTime 在 (now, now+24h] 之间且未超时的
+        List<ApprovalTask> warningTasks = this.approvalTaskMapper.selectList(
+                Wrappers.<ApprovalTask>lambdaQuery()
+                        .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                        .isNotNull(ApprovalTask::getTimeoutTime)
+                        .gt(ApprovalTask::getTimeoutTime, now)
+                        .le(ApprovalTask::getTimeoutTime, warningThreshold)
+        );
+        for (ApprovalTask task : warningTasks) {
+            this.sendReminder(task, false);
+        }
+    }
+
+    private void sendReminder(ApprovalTask task, boolean overdue) {
+        try {
+            ProcessInstance instance = this.processInstanceMapper.selectById(task.getInstanceId());
+            String processName = instance != null && instance.getProcessName() != null ? instance.getProcessName() : "";
+            String title = instance != null ? instance.getTitle() : "";
+            String nodeName = task.getNodeName();
+            this.notificationService.notifyTaskOverdue(
+                    task, processName, title, nodeName, overdue);
+        } catch (Exception e) {
+            log.error("[超时检查] 发送催办通知失败,taskId={}", task.getId(), e);
+        }
+    }
+}

+ 1 - 0
src/main/java/com/qqflow/engine/domain/flow/listener/NotificationEventListener.java

@@ -26,6 +26,7 @@ public class NotificationEventListener {
         try {
             notificationService.notifyTaskAssigned(
                     event.getAssigneeIds(),
+                    event.getAssigneeType(),
                     event.getProcessName(),
                     event.getInstanceTitle(),
                     event.getNodeName()

+ 81 - 0
src/main/java/com/qqflow/engine/domain/flow/mapper/AnalysisMapper.java

@@ -0,0 +1,81 @@
+package com.qqflow.engine.domain.flow.mapper;
+
+import com.qqflow.engine.domain.flow.dto.analysis.AnalysisOverviewDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.NodeStayStatDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.ProcessEfficiencyDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StuckInstanceDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StatusDistributionDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.TrendDTO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 流程数据分析 Mapper
+ */
+@Mapper
+public interface AnalysisMapper {
+
+    /**
+     * 已完成流程效率统计
+     */
+    List<ProcessEfficiencyDTO> selectCompletedEfficiency(
+            @Param("startTime") LocalDateTime startTime,
+            @Param("endTime") LocalDateTime endTime,
+            @Param("processDefinitionId") Long processDefinitionId);
+
+    /**
+     * 进行中节点停留统计
+     */
+    List<NodeStayStatDTO> selectInProgressByNode(
+            @Param("processDefinitionId") Long processDefinitionId);
+
+    /**
+     * 卡住流程实例数量
+     */
+    Long countStuckInstances(
+            @Param("nodeId") String nodeId,
+            @Param("processDefinitionId") Long processDefinitionId,
+            @Param("minStayMinutes") Long minStayMinutes);
+
+    /**
+     * 卡住流程实例明细
+     */
+    List<StuckInstanceDTO> selectStuckInstances(
+            @Param("nodeId") String nodeId,
+            @Param("processDefinitionId") Long processDefinitionId,
+            @Param("minStayMinutes") Long minStayMinutes,
+            @Param("offset") Long offset,
+            @Param("pageSize") Integer pageSize);
+
+    /**
+     * 流程实例概览统计(不含超时任务数)
+     */
+    AnalysisOverviewDTO selectInstanceOverview(
+            @Param("startTime") LocalDateTime startTime,
+            @Param("endTime") LocalDateTime endTime,
+            @Param("processDefinitionId") Long processDefinitionId);
+
+    /**
+     * 超时任务数
+     */
+    Long countTimeoutTasks(@Param("processDefinitionId") Long processDefinitionId);
+
+    /**
+     * 流程状态分布
+     */
+    List<StatusDistributionDTO> selectStatusDistribution(
+            @Param("startTime") LocalDateTime startTime,
+            @Param("endTime") LocalDateTime endTime,
+            @Param("processDefinitionId") Long processDefinitionId);
+
+    /**
+     * 近30天流程趋势
+     */
+    List<TrendDTO> selectTrend(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate,
+            @Param("processDefinitionId") Long processDefinitionId);
+}

+ 5 - 7
src/main/java/com/qqflow/engine/domain/flow/mapper/ApprovalTaskMapper.java

@@ -5,19 +5,13 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 import java.util.List;
 
 @Mapper
 public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
 
-    default List<ApprovalTask> selectPendingByAssigneeId(Long assigneeId) {
-        LambdaQueryWrapper<ApprovalTask> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(ApprovalTask::getAssigneeId, assigneeId)
-                .eq(ApprovalTask::getTaskStatus, 0);
-        return this.selectList(wrapper);
-    }
-
     default List<ApprovalTask> selectByInstanceId(Long instanceId) {
         LambdaQueryWrapper<ApprovalTask> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(ApprovalTask::getInstanceId, instanceId);
@@ -31,6 +25,9 @@ public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
         return this.selectOne(wrapper);
     }
 
+    @Select("SELECT * FROM bpm_approval_task WHERE id = #{id} FOR UPDATE")
+    ApprovalTask selectByIdForUpdate(Long id);
+
     com.baomidou.mybatisplus.extension.plugins.pagination.Page<ApprovalTask> selectTodoList(
             com.baomidou.mybatisplus.extension.plugins.pagination.Page<ApprovalTask> page,
             @Param("assigneeId") Long assigneeId,
@@ -46,5 +43,6 @@ public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
     com.baomidou.mybatisplus.extension.plugins.pagination.Page<ApprovalTask> selectCcList(
             com.baomidou.mybatisplus.extension.plugins.pagination.Page<ApprovalTask> page,
             @Param("assigneeId") Long assigneeId,
+            @Param("assigneeType") String assigneeType,
             @Param("processName") String processName);
 }

+ 9 - 0
src/main/java/com/qqflow/engine/domain/flow/mapper/AttachmentMapper.java

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

+ 2 - 16
src/main/java/com/qqflow/engine/domain/flow/mapper/ProcessInstanceMapper.java

@@ -1,28 +1,13 @@
 package com.qqflow.engine.domain.flow.mapper;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.qqflow.engine.domain.flow.po.ProcessInstance;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
-import java.util.List;
-
 @Mapper
 public interface ProcessInstanceMapper extends BaseMapper<ProcessInstance> {
 
-    default ProcessInstance selectByInstanceNo(String instanceNo) {
-        LambdaQueryWrapper<ProcessInstance> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(ProcessInstance::getInstanceNo, instanceNo);
-        return this.selectOne(wrapper);
-    }
-
-    default List<ProcessInstance> selectByApplicantId(Long applicantId) {
-        LambdaQueryWrapper<ProcessInstance> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(ProcessInstance::getApplicantId, applicantId);
-        return this.selectList(wrapper);
-    }
-
     com.baomidou.mybatisplus.extension.plugins.pagination.Page<ProcessInstance> selectInstanceList(
             com.baomidou.mybatisplus.extension.plugins.pagination.Page<ProcessInstance> page,
             @Param("applicantId") Long applicantId,
@@ -30,5 +15,6 @@ public interface ProcessInstanceMapper extends BaseMapper<ProcessInstance> {
 
     com.baomidou.mybatisplus.extension.plugins.pagination.Page<ProcessInstance> selectParticipatedList(
             com.baomidou.mybatisplus.extension.plugins.pagination.Page<ProcessInstance> page,
-            @Param("userId") Long userId);
+            @Param("userId") Long userId,
+            @Param("userType") String userType);
 }

+ 8 - 0
src/main/java/com/qqflow/engine/domain/flow/po/ApprovalRecord.java

@@ -63,6 +63,14 @@ public class ApprovalRecord {
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 
+    @TableField(exist = false)
+    @Schema(description = "实例标题")
+    private String instanceTitle;
+
+    @TableField(exist = false)
+    @Schema(description = "实例编号")
+    private String instanceNo;
+
     @TableLogic
     @TableField("deleted")
     @Schema(description = "是否删除")

+ 6 - 0
src/main/java/com/qqflow/engine/domain/flow/po/ApprovalTask.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableLogic;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.Version;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 
@@ -75,6 +76,11 @@ public class ApprovalTask {
     @Schema(description = "处理时间")
     private LocalDateTime handleTime;
 
+    @Version
+    @TableField("version")
+    @Schema(description = "乐观锁版本号")
+    private Integer version;
+
     @TableLogic
     @TableField("deleted")
     @Schema(description = "是否删除")

+ 74 - 0
src/main/java/com/qqflow/engine/domain/flow/po/Attachment.java

@@ -0,0 +1,74 @@
+package com.qqflow.engine.domain.flow.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("bpm_attachment")
+@Schema(description = "流程附件")
+public class Attachment {
+
+    @TableId(type = IdType.AUTO)
+    @Schema(description = "附件ID")
+    private Long id;
+
+    @TableField("instance_id")
+    @Schema(description = "流程实例ID")
+    private Long instanceId;
+
+    @TableField("task_id")
+    @Schema(description = "关联任务ID")
+    private Long taskId;
+
+    @TableField("record_id")
+    @Schema(description = "关联审批记录ID")
+    private Long recordId;
+
+    @TableField("node_id")
+    @Schema(description = "节点ID")
+    private String nodeId;
+
+    @TableField("node_name")
+    @Schema(description = "节点名称")
+    private String nodeName;
+
+    @TableField("file_name")
+    @Schema(description = "原始文件名")
+    private String fileName;
+
+    @TableField("file_url")
+    @Schema(description = "文件访问URL")
+    private String fileUrl;
+
+    @TableField("file_size")
+    @Schema(description = "文件大小(字节)")
+    private Long fileSize;
+
+    @TableField("uploader_id")
+    @Schema(description = "上传人ID")
+    private Long uploaderId;
+
+    @TableField("uploader_name")
+    @Schema(description = "上传人姓名")
+    private String uploaderName;
+
+    @TableField("uploader_type")
+    @Schema(description = "上传人类型:SYSTEM/ROLE")
+    private String uploaderType;
+
+    @TableField("create_time")
+    @Schema(description = "上传时间")
+    private LocalDateTime createTime;
+
+    @TableLogic
+    @TableField("deleted")
+    @Schema(description = "是否删除")
+    private Integer deleted;
+}

+ 4 - 0
src/main/java/com/qqflow/engine/domain/flow/po/ProcessDefinition.java

@@ -35,6 +35,10 @@ public class ProcessDefinition {
     @Schema(description = "表单ID")
     private Long formId;
 
+    @TableField("form_schema")
+    @Schema(description = "表单字段定义JSON")
+    private String formSchema;
+
     @TableField("model_json")
     @Schema(description = "模型JSON")
     private String modelJson;

+ 77 - 0
src/main/java/com/qqflow/engine/domain/flow/service/AnalysisService.java

@@ -0,0 +1,77 @@
+package com.qqflow.engine.domain.flow.service;
+
+import com.qqflow.engine.common.PageResult;
+import com.qqflow.engine.domain.flow.dto.analysis.AnalysisOverviewDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.NodeStayStatDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.ProcessEfficiencyDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StuckInstanceDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StatusDistributionDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.TrendDTO;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 流程数据分析服务
+ */
+public interface AnalysisService {
+
+    /**
+     * 已完成流程效率统计
+     *
+     * @param startTime           结束时间起
+     * @param endTime             结束时间止
+     * @param processDefinitionId 流程定义ID(可选)
+     * @return 流程效率列表
+     */
+    List<ProcessEfficiencyDTO> completedEfficiency(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId);
+
+    /**
+     * 进行中流程节点停留统计
+     *
+     * @param processDefinitionId 流程定义ID(可选)
+     * @return 节点停留统计列表
+     */
+    List<NodeStayStatDTO> inProgressByNode(Long processDefinitionId);
+
+    /**
+     * 节点下钻明细(卡住流程实例)
+     *
+     * @param nodeId              节点ID(可选)
+     * @param processDefinitionId 流程定义ID(可选)
+     * @param minStayMinutes      最小停留分钟数(可选)
+     * @param pageNum             页码
+     * @param pageSize            页大小
+     * @return 分页明细
+     */
+    PageResult<StuckInstanceDTO> stuckInstances(String nodeId, Long processDefinitionId,
+                                                Long minStayMinutes, Integer pageNum, Integer pageSize);
+
+    /**
+     * 数据分析看板概览 KPI
+     *
+     * @param startTime           开始时间(可选)
+     * @param endTime             结束时间(可选)
+     * @param processDefinitionId 流程定义ID(可选)
+     * @return 概览数据
+     */
+    AnalysisOverviewDTO overview(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId);
+
+    /**
+     * 流程状态分布
+     *
+     * @param startTime           开始时间(可选)
+     * @param endTime             结束时间(可选)
+     * @param processDefinitionId 流程定义ID(可选)
+     * @return 状态分布列表
+     */
+    List<StatusDistributionDTO> statusDistribution(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId);
+
+    /**
+     * 近30天流程趋势
+     *
+     * @param processDefinitionId 流程定义ID(可选)
+     * @return 趋势列表
+     */
+    List<TrendDTO> trend(Long processDefinitionId);
+}

+ 6 - 1
src/main/java/com/qqflow/engine/domain/flow/service/ApprovalTaskService.java

@@ -4,6 +4,7 @@ import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.domain.flow.dto.ApprovalRecordDTO;
 import com.qqflow.engine.domain.flow.dto.ApprovalTaskDTO;
 import com.qqflow.engine.domain.flow.dto.ApproveTaskDTO;
+import com.qqflow.engine.domain.flow.dto.BatchTaskDTO;
 import com.qqflow.engine.domain.flow.dto.TransferTaskDTO;
 
 import java.util.List;
@@ -18,6 +19,10 @@ public interface ApprovalTaskService {
 
     void reject(ApproveTaskDTO dto);
 
+    void batchApprove(BatchTaskDTO dto);
+
+    void batchReject(BatchTaskDTO dto);
+
     void returnTask(ApproveTaskDTO dto);
 
     void transfer(TransferTaskDTO dto);
@@ -28,7 +33,7 @@ public interface ApprovalTaskService {
 
     Long todoCount(Long assigneeId, String assigneeType);
 
-    PageResult<ApprovalTaskDTO> ccList(Long assigneeId, String processName, Integer pageNum, Integer pageSize);
+    PageResult<ApprovalTaskDTO> ccList(Long assigneeId, String assigneeType, String processName, Integer pageNum, Integer pageSize);
 
     void readCc(Long taskId);
 }

+ 15 - 1
src/main/java/com/qqflow/engine/domain/flow/service/NotificationService.java

@@ -1,5 +1,7 @@
 package com.qqflow.engine.domain.flow.service;
 
+import com.qqflow.engine.domain.flow.po.ApprovalTask;
+
 import java.util.List;
 
 /**
@@ -12,11 +14,12 @@ public interface NotificationService {
      * 发送任务分配通知(待我处理)
      *
      * @param assigneeIds   接收人ID列表
+     * @param assigneeType  接收人类型(USER / ROLE)
      * @param processName   流程名称
      * @param instanceTitle 实例标题
      * @param nodeName      当前节点名称
      */
-    void notifyTaskAssigned(List<Long> assigneeIds, String processName, String instanceTitle, String nodeName);
+    void notifyTaskAssigned(List<Long> assigneeIds, String assigneeType, String processName, String instanceTitle, String nodeName);
 
     /**
      * 发送任务完成通知(审批结果通知)
@@ -40,4 +43,15 @@ public interface NotificationService {
      * @param result        结果(通过/拒绝)
      */
     void notifyProcessCompleted(Long applicantId, String processName, String instanceTitle, String result);
+
+    /**
+     * 发送任务超时/催办通知
+     *
+     * @param task          待处理任务(包含 assigneeId / assigneeType)
+     * @param processName   流程名称
+     * @param instanceTitle 实例标题
+     * @param nodeName      节点名称
+     * @param overdue       true-已超时,false-即将超时
+     */
+    void notifyTaskOverdue(ApprovalTask task, String processName, String instanceTitle, String nodeName, boolean overdue);
 }

+ 6 - 1
src/main/java/com/qqflow/engine/domain/flow/service/ProcessInstanceService.java

@@ -1,6 +1,7 @@
 package com.qqflow.engine.domain.flow.service;
 
 import com.qqflow.engine.common.PageResult;
+import com.qqflow.engine.domain.flow.dto.AttachmentDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessInstanceDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessProgressDTO;
 import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
@@ -17,7 +18,11 @@ public interface ProcessInstanceService {
 
     void revoke(Long id);
 
+    void delete(Long id);
+
     ProcessProgressDTO getProgress(Long id);
 
-    PageResult<ProcessInstanceDTO> participatedList(Long userId, Integer pageNum, Integer pageSize);
+    PageResult<ProcessInstanceDTO> participatedList(Long userId, String userType, Integer pageNum, Integer pageSize);
+
+    List<AttachmentDTO> listAttachments(Long instanceId);
 }

+ 181 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/AnalysisServiceImpl.java

@@ -0,0 +1,181 @@
+package com.qqflow.engine.domain.flow.service.impl;
+
+import com.qqflow.engine.common.PageResult;
+import com.qqflow.engine.common.util.RedisCache;
+import com.qqflow.engine.domain.flow.dto.analysis.AnalysisOverviewDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.NodeStayStatDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.ProcessEfficiencyDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StuckInstanceDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.StatusDistributionDTO;
+import com.qqflow.engine.domain.flow.dto.analysis.TrendDTO;
+import com.qqflow.engine.domain.flow.mapper.AnalysisMapper;
+import com.qqflow.engine.domain.flow.service.AnalysisService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * 流程数据分析服务实现
+ */
+@Service
+@RequiredArgsConstructor
+public class AnalysisServiceImpl implements AnalysisService {
+
+    private final AnalysisMapper analysisMapper;
+    private final RedisCache redisCache;
+
+    private static final String KEY_COMPLETED_EFFICIENCY = "analysis:completed-efficiency:%s:%s:%s";
+    private static final String KEY_IN_PROGRESS_BY_NODE = "analysis:in-progress-by-node:%s";
+    private static final String KEY_STUCK_INSTANCES = "analysis:stuck-instances:%s:%s:%s:%s:%s";
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    @Override
+    public List<ProcessEfficiencyDTO> completedEfficiency(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId) {
+        String key = String.format(KEY_COMPLETED_EFFICIENCY,
+                startTime == null ? "null" : startTime.toString(),
+                endTime == null ? "null" : endTime.toString(),
+                processDefinitionId == null ? "null" : processDefinitionId);
+        List<ProcessEfficiencyDTO> cached = redisCache.getCacheObject(key);
+        if (cached != null) {
+            return cached;
+        }
+        List<ProcessEfficiencyDTO> result = analysisMapper.selectCompletedEfficiency(startTime, endTime, processDefinitionId);
+        redisCache.setCacheObject(key, result, 5, TimeUnit.MINUTES);
+        return result;
+    }
+
+    @Override
+    public List<NodeStayStatDTO> inProgressByNode(Long processDefinitionId) {
+        String key = String.format(KEY_IN_PROGRESS_BY_NODE,
+                processDefinitionId == null ? "null" : processDefinitionId);
+        List<NodeStayStatDTO> cached = redisCache.getCacheObject(key);
+        if (cached != null) {
+            return cached;
+        }
+        List<NodeStayStatDTO> result = analysisMapper.selectInProgressByNode(processDefinitionId);
+        redisCache.setCacheObject(key, result, 5, TimeUnit.MINUTES);
+        return result;
+    }
+
+    @Override
+    public PageResult<StuckInstanceDTO> stuckInstances(String nodeId, Long processDefinitionId,
+                                                       Long minStayMinutes, Integer pageNum, Integer pageSize) {
+        int current = pageNum == null || pageNum < 1 ? 1 : pageNum;
+        int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
+        long offset = (long) (current - 1) * size;
+        String key = String.format(KEY_STUCK_INSTANCES,
+                nodeId == null ? "null" : nodeId,
+                processDefinitionId == null ? "null" : processDefinitionId,
+                minStayMinutes == null ? "null" : minStayMinutes,
+                current,
+                size);
+        PageResult<StuckInstanceDTO> cached = redisCache.getCacheObject(key);
+        if (cached != null) {
+            return cached;
+        }
+        Long total = analysisMapper.countStuckInstances(nodeId, processDefinitionId, minStayMinutes);
+        if (total == null || total == 0) {
+            return PageResult.of(0L, List.of());
+        }
+        List<StuckInstanceDTO> list = analysisMapper.selectStuckInstances(
+                nodeId, processDefinitionId, minStayMinutes, offset, size);
+        PageResult<StuckInstanceDTO> result = PageResult.of(total, list);
+        redisCache.setCacheObject(key, result, 5, TimeUnit.MINUTES);
+        return result;
+    }
+
+    @Override
+    public AnalysisOverviewDTO overview(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId) {
+        AnalysisOverviewDTO overview = analysisMapper.selectInstanceOverview(startTime, endTime, processDefinitionId);
+        if (overview == null) {
+            overview = new AnalysisOverviewDTO();
+            overview.setTotalInstances(0L);
+            overview.setCompletedCount(0L);
+            overview.setRunningCount(0L);
+            overview.setRejectedCount(0L);
+            overview.setRevokedCount(0L);
+            overview.setAvgDurationMinutes(0L);
+        }
+        Long timeoutCount = analysisMapper.countTimeoutTasks(processDefinitionId);
+        overview.setTimeoutCount(timeoutCount == null ? 0L : timeoutCount);
+        long running = overview.getRunningCount() == null ? 0L : overview.getRunningCount();
+        long timeout = overview.getTimeoutCount();
+        double rate = running == 0 ? 0.0 : Math.round(timeout * 1000.0 / running) / 10.0;
+        overview.setTimeoutRate(rate);
+        return overview;
+    }
+
+    @Override
+    public List<StatusDistributionDTO> statusDistribution(LocalDateTime startTime, LocalDateTime endTime, Long processDefinitionId) {
+        List<StatusDistributionDTO> list = analysisMapper.selectStatusDistribution(startTime, endTime, processDefinitionId);
+        if (list == null) {
+            return List.of();
+        }
+        for (StatusDistributionDTO dto : list) {
+            dto.setStatusName(statusName(dto.getStatus()));
+        }
+        return list;
+    }
+
+    @Override
+    public List<TrendDTO> trend(Long processDefinitionId) {
+        LocalDate endDate = LocalDate.now();
+        LocalDate startDate = endDate.minusDays(29);
+        List<TrendDTO> dbList = analysisMapper.selectTrend(
+                startDate.format(DATE_FORMATTER),
+                endDate.format(DATE_FORMATTER),
+                processDefinitionId);
+        Map<String, TrendDTO> dbMap = dbList == null
+                ? new HashMap<>()
+                : dbList.stream().collect(Collectors.toMap(TrendDTO::getDate, dto -> dto, (a, b) -> a));
+
+        List<TrendDTO> result = new ArrayList<>(30);
+        for (int i = 0; i < 30; i++) {
+            String date = startDate.plusDays(i).format(DATE_FORMATTER);
+            TrendDTO dto = dbMap.get(date);
+            if (dto == null) {
+                dto = new TrendDTO();
+                dto.setDate(date);
+                dto.setStartedCount(0L);
+                dto.setCompletedCount(0L);
+                dto.setRejectedCount(0L);
+            } else {
+                dto.setStartedCount(nullToZero(dto.getStartedCount()));
+                dto.setCompletedCount(nullToZero(dto.getCompletedCount()));
+                dto.setRejectedCount(nullToZero(dto.getRejectedCount()));
+            }
+            result.add(dto);
+        }
+        return result;
+    }
+
+    private long nullToZero(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    private String statusName(Integer status) {
+        if (status == null) {
+            return "未知";
+        }
+        return switch (status) {
+            case 0 -> "待接收";
+            case 1 -> "待处理";
+            case 2 -> "已通过";
+            case 3 -> "已拒绝";
+            case 4 -> "已回退";
+            case 5 -> "整体完成";
+            case 6 -> "已撤回";
+            case 7 -> "已终止";
+            default -> "未知";
+        };
+    }
+}

+ 40 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskHelper.java

@@ -0,0 +1,40 @@
+package com.qqflow.engine.domain.flow.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.qqflow.engine.domain.flow.enums.TaskStatus;
+import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
+import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 审批任务通用辅助类(包内共享)
+ */
+@Component
+@RequiredArgsConstructor
+class ApprovalTaskHelper {
+
+    private final ApprovalTaskMapper approvalTaskMapper;
+
+    /**
+     * 将实例下所有 PENDING 任务取消为 SKIPPED。
+     *
+     * @param instanceId 流程实例 ID
+     * @param handleTime 处理时间;为 null 时取当前时间
+     */
+    void cancelPendingTasks(Long instanceId, LocalDateTime handleTime) {
+        LambdaQueryWrapper<ApprovalTask> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ApprovalTask::getInstanceId, instanceId)
+                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode());
+        List<ApprovalTask> pendingTasks = this.approvalTaskMapper.selectList(wrapper);
+        LocalDateTime now = handleTime != null ? handleTime : LocalDateTime.now();
+        for (ApprovalTask task : pendingTasks) {
+            task.setTaskStatus(TaskStatus.SKIPPED.getCode());
+            task.setHandleTime(now);
+            this.approvalTaskMapper.updateById(task);
+        }
+    }
+}

+ 297 - 46
src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskServiceImpl.java

@@ -1,5 +1,6 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.exception.BusinessException;
@@ -7,45 +8,72 @@ import com.qqflow.engine.common.util.SecurityUtils;
 import com.qqflow.engine.config.security.LoginUser;
 import com.qqflow.engine.domain.flow.assembler.ApprovalRecordAssembler;
 import com.qqflow.engine.domain.flow.assembler.ApprovalTaskAssembler;
+import com.qqflow.engine.domain.flow.assembler.ApprovalTaskDTOAssembler;
 import com.qqflow.engine.domain.flow.dto.ApprovalRecordDTO;
 import com.qqflow.engine.domain.flow.dto.ApprovalTaskDTO;
 import com.qqflow.engine.domain.flow.dto.ApproveTaskDTO;
+import com.qqflow.engine.domain.flow.dto.BatchTaskDTO;
 import com.qqflow.engine.domain.flow.dto.TransferTaskDTO;
 import com.qqflow.engine.domain.flow.enums.ApprovalAction;
 import com.qqflow.engine.domain.flow.enums.ApprovalResult;
+import com.qqflow.engine.domain.flow.enums.NodeType;
 import com.qqflow.engine.domain.flow.enums.ProcessStatus;
 import com.qqflow.engine.domain.flow.enums.TaskStatus;
+import com.qqflow.engine.domain.flow.event.TaskCompletedEvent;
 import com.qqflow.engine.domain.flow.mapper.ApprovalRecordMapper;
 import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
+import com.qqflow.engine.domain.flow.mapper.AttachmentMapper;
 import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
 import com.qqflow.engine.domain.flow.po.ApprovalRecord;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import com.qqflow.engine.domain.flow.po.Attachment;
 import com.qqflow.engine.domain.flow.po.ProcessInstance;
-import com.qqflow.engine.domain.flow.event.TaskCompletedEvent;
 import com.qqflow.engine.domain.flow.service.ApprovalTaskService;
 import com.qqflow.engine.domain.flow.service.FlowEngineService;
+import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.entity.SysUserRole;
+import com.qqflow.engine.domain.system.mapper.SysUserMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.context.ApplicationEventPublisher;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
 
+import java.io.File;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_USER;
+import static com.qqflow.engine.common.constant.SecurityConstants.ROLE_FLOW_MANAGER;
+import static com.qqflow.engine.common.constant.SecurityConstants.ROLE_SUPER_ADMIN;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
 @Service
 @RequiredArgsConstructor
 public class ApprovalTaskServiceImpl implements ApprovalTaskService {
 
     private final ApprovalTaskMapper approvalTaskMapper;
     private final ApprovalRecordMapper approvalRecordMapper;
+    private final AttachmentMapper attachmentMapper;
+    private final ObjectMapper objectMapper;
     private final ProcessInstanceMapper processInstanceMapper;
     private final ApprovalRecordAssembler approvalRecordAssembler;
     private final ApprovalTaskAssembler approvalTaskAssembler;
+    private final ApprovalTaskDTOAssembler approvalTaskDTOAssembler;
+    private final ApprovalTaskHelper approvalTaskHelper;
     private final FlowEngineService flowEngineService;
     private final ApplicationEventPublisher eventPublisher;
     private final SysUserRoleMapper sysUserRoleMapper;
+    private final SysUserMapper sysUserMapper;
 
     @Override
     public PageResult<ApprovalTaskDTO> todoList(Long assigneeId, String assigneeType, String processName, Integer pageNum, Integer pageSize) {
@@ -53,10 +81,29 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         this.approvalTaskMapper.selectTodoList(page, assigneeId, assigneeType, processName);
         List<ApprovalTaskDTO> records = page.getRecords().stream()
                 .map(ApprovalTaskDTO::of)
+                .peek(this::fillUrgency)
                 .collect(Collectors.toList());
+        this.approvalTaskDTOAssembler.fillAssigneeName(records);
         return PageResult.of(page.getTotal(), records);
     }
 
+    private void fillUrgency(ApprovalTaskDTO dto) {
+        if (dto == null || dto.getTimeoutTime() == null) {
+            dto.setUrgency(0);
+            return;
+        }
+        LocalDateTime now = LocalDateTime.now();
+        long remaining = ChronoUnit.MINUTES.between(now, dto.getTimeoutTime());
+        dto.setRemainingMinutes(remaining);
+        if (remaining <= 0) {
+            dto.setUrgency(2);
+        } else if (remaining <= 24 * 60) {
+            dto.setUrgency(1);
+        } else {
+            dto.setUrgency(0);
+        }
+    }
+
     @Override
     public PageResult<ApprovalTaskDTO> handledList(Long assigneeId, String assigneeType, String processName, Integer pageNum, Integer pageSize) {
         Page<ApprovalTask> page = new Page<>(pageNum, pageSize);
@@ -64,13 +111,14 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         List<ApprovalTaskDTO> records = page.getRecords().stream()
                 .map(ApprovalTaskDTO::of)
                 .collect(Collectors.toList());
+        this.approvalTaskDTOAssembler.fillAssigneeName(records);
         return PageResult.of(page.getTotal(), records);
     }
 
     @Override
     @Transactional
     public void approve(ApproveTaskDTO dto) {
-        ApprovalTask task = this.getPendingTask(dto.getTaskId());
+        ApprovalTask task = this.getPendingTaskForUpdate(dto.getTaskId());
         this.checkTaskPermission(task);
         ProcessInstance instance = this.getActiveInstance(task.getInstanceId());
         Long operatorId = SecurityUtils.getUserId();
@@ -82,7 +130,7 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     @Override
     @Transactional
     public void reject(ApproveTaskDTO dto) {
-        ApprovalTask task = this.getPendingTask(dto.getTaskId());
+        ApprovalTask task = this.getPendingTaskForUpdate(dto.getTaskId());
         this.checkTaskPermission(task);
         ProcessInstance instance = this.getActiveInstance(task.getInstanceId());
         Long operatorId = SecurityUtils.getUserId();
@@ -93,8 +141,32 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
 
     @Override
     @Transactional
+    public void batchApprove(BatchTaskDTO dto) {
+        for (Long taskId : dto.getTaskIds()) {
+            ApproveTaskDTO approveDto = new ApproveTaskDTO();
+            approveDto.setTaskId(taskId);
+            approveDto.setComment(dto.getComment());
+            approveDto.setAttachmentUrls(dto.getAttachmentUrls());
+            this.approve(approveDto);
+        }
+    }
+
+    @Override
+    @Transactional
+    public void batchReject(BatchTaskDTO dto) {
+        for (Long taskId : dto.getTaskIds()) {
+            ApproveTaskDTO rejectDto = new ApproveTaskDTO();
+            rejectDto.setTaskId(taskId);
+            rejectDto.setComment(dto.getComment());
+            rejectDto.setAttachmentUrls(dto.getAttachmentUrls());
+            this.reject(rejectDto);
+        }
+    }
+
+    @Override
+    @Transactional
     public void returnTask(ApproveTaskDTO dto) {
-        ApprovalTask task = this.getPendingTask(dto.getTaskId());
+        ApprovalTask task = this.getPendingTaskForUpdate(dto.getTaskId());
         this.checkTaskPermission(task);
         ProcessInstance instance = this.getActiveInstance(task.getInstanceId());
         Long operatorId = SecurityUtils.getUserId();
@@ -106,11 +178,12 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     @Override
     @Transactional
     public void transfer(TransferTaskDTO dto) {
-        ApprovalTask task = this.getPendingTask(dto.getTaskId());
+        ApprovalTask task = this.getPendingTaskForUpdate(dto.getTaskId());
         this.checkTaskPermission(task);
         ProcessInstance instance = this.getActiveInstance(task.getInstanceId());
         Long operatorId = SecurityUtils.getUserId();
         Long transferToUserId = dto.getTransferToUserId() != null ? dto.getTransferToUserId() : dto.getTransferTo();
+        this.validateTransferTarget(transferToUserId, operatorId);
         this.saveRecord(task, instance, operatorId, ApprovalAction.TRANSFER, ApprovalResult.TRANSFER.getCode(), dto.getComment(), null);
         this.updateTaskToTransferred(task);
         this.createTransferTask(task, instance, transferToUserId);
@@ -122,17 +195,61 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     public void addSign(Long taskId, Long assigneeId) {
         ApprovalTask task = this.getPendingTask(taskId);
         this.checkTaskPermission(task);
+        if (assigneeId == null) {
+            throw new BusinessException("加签人不能为空");
+        }
+        this.validateTransferTarget(assigneeId, null);
         ApprovalTask newTask = this.approvalTaskAssembler.buildNew(
                 task.getInstanceId(), task.getNodeId(), task.getNodeName(),
-                task.getNodeType(), assigneeId, task.getAssigneeType(), TaskStatus.PENDING.getCode()
+                task.getNodeType(), assigneeId, ASSIGNEE_TYPE_USER, TaskStatus.PENDING.getCode()
         );
         this.approvalTaskMapper.insert(newTask);
-        this.publishTaskCompletedEvent(task, null, SecurityUtils.getUserId(),
-                ApprovalAction.TRANSFER.getCode(), ApprovalResult.TRANSFER.getCode());
+        ProcessInstance instance = this.processInstanceMapper.selectById(task.getInstanceId());
+        Long operatorId = SecurityUtils.getUserId();
+        this.saveRecord(task, instance, operatorId, ApprovalAction.ADD_SIGN, ApprovalResult.PASS.getCode(), null, null);
+        this.publishTaskCompletedEvent(task, instance, operatorId,
+                ApprovalAction.ADD_SIGN.getCode(), ApprovalResult.PASS.getCode());
+    }
+
+    private void validateTransferTarget(Long targetUserId, Long operatorId) {
+        if (targetUserId == null) {
+            throw new BusinessException("转办/加签人不能为空");
+        }
+        if (operatorId != null && targetUserId.equals(operatorId)) {
+            throw new BusinessException("不能转办/加签给自己");
+        }
+        SysUser targetUser = this.sysUserMapper.selectById(targetUserId);
+        if (targetUser == null) {
+            throw new BusinessException("转办/加签人不存在");
+        }
+        if (targetUser.getStatus() != null && targetUser.getStatus() != 0) {
+            throw new BusinessException("转办/加签人已被禁用");
+        }
     }
 
     @Override
     public List<ApprovalRecordDTO> history(Long instanceId) {
+        ProcessInstance instance = this.processInstanceMapper.selectById(instanceId);
+        if (instance == null) {
+            throw new BusinessException("流程实例不存在");
+        }
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null) {
+            throw new BusinessException("未登录");
+        }
+        Long userId = loginUser.getUserId();
+        String userType = loginUser.getUserType();
+        // 申请人、处理人、管理员可见
+        boolean visible = isApplicantOrAdmin(instance, loginUser, userId, userType);
+        if (!visible) {
+            visible = this.isTaskAssignee(instanceId, userId, userType);
+        }
+        if (!visible) {
+            visible = this.isRecordOperator(instanceId, userId, userType);
+        }
+        if (!visible) {
+            throw new BusinessException("无权查看该流程历史");
+        }
         List<ApprovalRecord> records = this.approvalRecordMapper.selectRecordListByInstanceId(instanceId);
         return records.stream()
                 .map(ApprovalRecordDTO::of)
@@ -140,12 +257,13 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     }
 
     @Override
-    public PageResult<ApprovalTaskDTO> ccList(Long assigneeId, String processName, Integer pageNum, Integer pageSize) {
+    public PageResult<ApprovalTaskDTO> ccList(Long assigneeId, String assigneeType, String processName, Integer pageNum, Integer pageSize) {
         Page<ApprovalTask> page = new Page<>(pageNum, pageSize);
-        this.approvalTaskMapper.selectCcList(page, assigneeId, processName);
+        this.approvalTaskMapper.selectCcList(page, assigneeId, assigneeType, processName);
         List<ApprovalTaskDTO> records = page.getRecords().stream()
                 .map(ApprovalTaskDTO::of)
                 .collect(Collectors.toList());
+        this.approvalTaskDTOAssembler.fillAssigneeName(records);
         return PageResult.of(page.getTotal(), records);
     }
 
@@ -157,8 +275,26 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
             throw new BusinessException("抄送任务不存在");
         }
         Long operatorId = SecurityUtils.getUserId();
-        if (!task.getAssigneeId().equals(operatorId)) {
-            throw new BusinessException("无权操作该抄送");
+        String userType = getCurrentUserType();
+        String assigneeType = task.getAssigneeType();
+        if (ASSIGNEE_TYPE_ROLE.equals(assigneeType)) {
+            if (USER_TYPE_ROLE.equals(userType)) {
+                if (!task.getAssigneeId().equals(operatorId)) {
+                    throw new BusinessException("无权操作该抄送");
+                }
+            } else {
+                List<SysUserRole> userRoles = this.sysUserRoleMapper.selectList(
+                        new LambdaQueryWrapper<SysUserRole>()
+                                .eq(SysUserRole::getUserId, operatorId)
+                                .eq(SysUserRole::getRoleId, task.getAssigneeId()));
+                if (CollectionUtils.isEmpty(userRoles)) {
+                    throw new BusinessException("无权操作该抄送");
+                }
+            }
+        } else {
+            if (!USER_TYPE_SYSTEM.equals(userType) || !task.getAssigneeId().equals(operatorId)) {
+                throw new BusinessException("无权操作该抄送");
+            }
         }
         if (!TaskStatus.PENDING.getCode().equals(task.getTaskStatus())) {
             return; // 已经是已读状态
@@ -167,21 +303,21 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
                 new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ApprovalTask>()
                         .eq(ApprovalTask::getId, taskId)
                         .set(ApprovalTask::getTaskStatus, TaskStatus.HANDLED.getCode())
-                        .set(ApprovalTask::getHandleTime, java.time.LocalDateTime.now()));
+                        .set(ApprovalTask::getHandleTime, LocalDateTime.now()));
     }
 
     @Override
     public Long todoCount(Long assigneeId, String assigneeType) {
-        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ApprovalTask> wrapper =
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ApprovalTask>();
+        LambdaQueryWrapper<ApprovalTask> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(ApprovalTask::getAssigneeId, assigneeId)
-                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode());
-        if ("ROLE".equals(assigneeType)) {
-            wrapper.eq(ApprovalTask::getAssigneeType, "ROLE");
+                .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
+                .ne(ApprovalTask::getNodeType, NodeType.CC.getCode());
+        if (ASSIGNEE_TYPE_ROLE.equals(assigneeType)) {
+            wrapper.eq(ApprovalTask::getAssigneeType, ASSIGNEE_TYPE_ROLE);
         } else {
             wrapper.and(w -> w.isNull(ApprovalTask::getAssigneeType)
                     .or().eq(ApprovalTask::getAssigneeType, "")
-                    .or().eq(ApprovalTask::getAssigneeType, "USER"));
+                    .or().eq(ApprovalTask::getAssigneeType, ASSIGNEE_TYPE_USER));
         }
         return this.approvalTaskMapper.selectCount(wrapper);
     }
@@ -191,6 +327,26 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         if (task == null) {
             throw new BusinessException("任务不存在或已处理");
         }
+        if (NodeType.CC.getCode().equals(task.getNodeType())) {
+            throw new BusinessException("抄送任务无需审批");
+        }
+        return task;
+    }
+
+    private ApprovalTask getPendingTaskForUpdate(Long taskId) {
+        if (taskId == null) {
+            throw new BusinessException("任务ID不能为空");
+        }
+        ApprovalTask task = this.approvalTaskMapper.selectByIdForUpdate(taskId);
+        if (task == null) {
+            throw new BusinessException("任务不存在或已处理");
+        }
+        if (!TaskStatus.PENDING.getCode().equals(task.getTaskStatus())) {
+            throw new BusinessException("任务不存在或已处理");
+        }
+        if (NodeType.CC.getCode().equals(task.getNodeType())) {
+            throw new BusinessException("抄送任务无需审批");
+        }
         return task;
     }
 
@@ -215,67 +371,162 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     private void checkTaskPermission(ApprovalTask task) {
         Long operatorId = SecurityUtils.getUserId();
         String assigneeType = task.getAssigneeType();
-        String userType = SecurityUtils.getLoginUser() != null ? SecurityUtils.getLoginUser().getUserType() : "SYSTEM";
-        if ("ROLE".equals(assigneeType)) {
+        String userType = getCurrentUserType();
+        if (ASSIGNEE_TYPE_ROLE.equals(assigneeType)) {
             // ROLE类型任务:如果当前登录用户就是该角色账号,直接允许
-            if ("ROLE".equals(userType) && task.getAssigneeId().equals(operatorId)) {
+            if (USER_TYPE_ROLE.equals(userType) && task.getAssigneeId().equals(operatorId)) {
                 return;
             }
             // 否则检查SYSTEM用户是否拥有该角色
-            List<SysUserRole> userRoles = this.sysUserRoleMapper.selectList(
-                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUserRole>()
-                            .eq(SysUserRole::getUserId, operatorId)
-                            .eq(SysUserRole::getRoleId, task.getAssigneeId()));
-            if (userRoles == null || userRoles.isEmpty()) {
-                throw new BusinessException("无权操作该任务");
+            if (!USER_TYPE_ROLE.equals(userType)) {
+                List<SysUserRole> userRoles = this.sysUserRoleMapper.selectList(
+                        new LambdaQueryWrapper<SysUserRole>()
+                                .eq(SysUserRole::getUserId, operatorId)
+                                .eq(SysUserRole::getRoleId, task.getAssigneeId()));
+                if (CollectionUtils.isEmpty(userRoles)) {
+                    throw new BusinessException("无权操作该任务");
+                }
+                return;
             }
+            throw new BusinessException("无权操作该任务");
         } else {
-            if (!task.getAssigneeId().equals(operatorId)) {
+            // USER/SELF/LEADER 类型任务仅允许 SYSTEM 用户本人处理
+            if (!USER_TYPE_SYSTEM.equals(userType) || !task.getAssigneeId().equals(operatorId)) {
                 throw new BusinessException("无权操作该任务");
             }
         }
     }
 
+    private String getCurrentUserType() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        return loginUser != null ? loginUser.getUserType() : USER_TYPE_SYSTEM;
+    }
+
+    private boolean isApplicantOrAdmin(ProcessInstance instance, LoginUser loginUser, Long userId, String userType) {
+        if (!USER_TYPE_ROLE.equals(userType) && userId.equals(instance.getApplicantId())) {
+            return true;
+        }
+        return !USER_TYPE_ROLE.equals(userType) && loginUser.getRoles() != null
+                && (loginUser.getRoles().contains(ROLE_SUPER_ADMIN) || loginUser.getRoles().contains(ROLE_FLOW_MANAGER));
+    }
+
+    private boolean isTaskAssignee(Long instanceId, Long userId, String userType) {
+        List<ApprovalTask> tasks = this.approvalTaskMapper.selectByInstanceId(instanceId);
+        for (ApprovalTask task : tasks) {
+            if (userId.equals(task.getAssigneeId())
+                    && (USER_TYPE_ROLE.equals(userType) ? ASSIGNEE_TYPE_ROLE.equals(task.getAssigneeType()) : !ASSIGNEE_TYPE_ROLE.equals(task.getAssigneeType()))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isRecordOperator(Long instanceId, Long userId, String userType) {
+        List<ApprovalRecord> records = this.approvalRecordMapper.selectRecordListByInstanceId(instanceId);
+        for (ApprovalRecord record : records) {
+            if (userId.equals(record.getOperatorId()) && !USER_TYPE_ROLE.equals(userType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private void saveRecord(ApprovalTask task, ProcessInstance instance, Long operatorId,
                             ApprovalAction action, String result, String comment, String attachmentUrls) {
-        String operatorName = null;
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        if (loginUser != null) {
-            operatorName = loginUser.getRealName();
-        }
+        String operatorName = loginUser != null ? loginUser.getRealName() : null;
         ApprovalRecord record = this.approvalRecordAssembler.buildNew(
                 task.getId(), instance.getId(), task.getNodeId(), task.getNodeName(),
                 operatorId, operatorName, action.getCode(), result, comment, attachmentUrls
         );
         this.approvalRecordMapper.insert(record);
+        this.saveAttachments(instance.getId(), task.getId(), record.getId(),
+                task.getNodeId(), task.getNodeName(), attachmentUrls, loginUser);
+    }
+
+    private void saveAttachments(Long instanceId, Long taskId, Long recordId,
+                                 String nodeId, String nodeName,
+                                 String attachmentUrls, LoginUser loginUser) {
+        List<String> urls = this.parseAttachmentUrls(attachmentUrls);
+        if (urls.isEmpty()) {
+            return;
+        }
+        for (String url : urls) {
+            Attachment attachment = this.buildAttachment(instanceId, taskId, recordId,
+                    nodeId, nodeName, url, loginUser);
+            this.attachmentMapper.insert(attachment);
+        }
+    }
+
+    private Attachment buildAttachment(Long instanceId, Long taskId, Long recordId,
+                                       String nodeId, String nodeName,
+                                       String url, LoginUser loginUser) {
+        Attachment attachment = new Attachment();
+        attachment.setInstanceId(instanceId);
+        attachment.setTaskId(taskId);
+        attachment.setRecordId(recordId);
+        attachment.setNodeId(nodeId);
+        attachment.setNodeName(nodeName);
+        attachment.setFileUrl(url);
+        String fileName = url;
+        int lastSlash = url.lastIndexOf('/');
+        if (lastSlash >= 0 && lastSlash < url.length() - 1) {
+            fileName = url.substring(lastSlash + 1);
+        }
+        attachment.setFileName(fileName);
+        try {
+            String path = url.startsWith("/uploads/") ? url.substring(1) : url;
+            File file = new File(path);
+            if (file.exists() && file.isFile()) {
+                attachment.setFileSize(file.length());
+            }
+        } catch (Exception ignored) {
+            // 无法读取文件大小时忽略
+        }
+        if (loginUser != null) {
+            attachment.setUploaderId(loginUser.getUserId());
+            attachment.setUploaderName(loginUser.getRealName());
+            attachment.setUploaderType(loginUser.getUserType());
+        }
+        return attachment;
+    }
+
+    private List<String> parseAttachmentUrls(String attachmentUrls) {
+        if (attachmentUrls == null || attachmentUrls.isBlank()) {
+            return Collections.emptyList();
+        }
+        String trimmed = attachmentUrls.trim();
+        if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+            try {
+                return this.objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
+            } catch (Exception ignored) {
+                // 解析失败时降级为字符串处理
+            }
+        }
+        return List.of(trimmed);
     }
 
     private void updateTaskToTransferred(ApprovalTask task) {
-        this.approvalTaskMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ApprovalTask>()
-                        .eq(ApprovalTask::getId, task.getId())
-                        .set(ApprovalTask::getTaskStatus, TaskStatus.TRANSFERRED.getCode()));
+        task.setTaskStatus(TaskStatus.TRANSFERRED.getCode());
+        task.setApprovalResult(ApprovalResult.TRANSFER.getCode());
+        task.setHandleTime(LocalDateTime.now());
+        this.approvalTaskMapper.updateById(task);
     }
 
     private void createTransferTask(ApprovalTask task, ProcessInstance instance, Long transferToUserId) {
         ApprovalTask newTask = this.approvalTaskAssembler.buildNew(
                 instance.getId(), task.getNodeId(), task.getNodeName(),
-                task.getNodeType(), transferToUserId, "USER", TaskStatus.PENDING.getCode()
+                task.getNodeType(), transferToUserId, ASSIGNEE_TYPE_USER, TaskStatus.PENDING.getCode()
         );
         this.approvalTaskMapper.insert(newTask);
     }
 
     private void publishTaskCompletedEvent(ApprovalTask task, ProcessInstance instance, Long operatorId,
                                            String action, String result) {
-        String operatorName = null;
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        if (loginUser != null) {
-            operatorName = loginUser.getRealName();
-        }
-        ProcessInstance inst = instance;
-        if (inst == null) {
-            inst = this.processInstanceMapper.selectById(task.getInstanceId());
-        }
+        String operatorName = loginUser != null ? loginUser.getRealName() : null;
+        ProcessInstance inst = Objects.requireNonNullElseGet(instance,
+                () -> this.processInstanceMapper.selectById(task.getInstanceId()));
         String title = inst != null ? inst.getTitle() : "";
         this.eventPublisher.publishEvent(new TaskCompletedEvent(
                 this, task.getInstanceId(), title, "", task.getNodeName(),

+ 320 - 88
src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java

@@ -8,6 +8,8 @@ import com.qqflow.engine.domain.flow.enums.ApprovalResult;
 import com.qqflow.engine.domain.flow.enums.NodeType;
 import com.qqflow.engine.domain.flow.enums.ProcessStatus;
 import com.qqflow.engine.domain.flow.enums.TaskStatus;
+import com.qqflow.engine.domain.flow.event.ProcessCompletedEvent;
+import com.qqflow.engine.domain.flow.event.TaskAssignedEvent;
 import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
 import com.qqflow.engine.domain.flow.mapper.ProcessDefinitionMapper;
 import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
@@ -17,8 +19,6 @@ import com.qqflow.engine.domain.flow.model.FlowNode;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import com.qqflow.engine.domain.flow.po.ProcessDefinition;
 import com.qqflow.engine.domain.flow.po.ProcessInstance;
-import com.qqflow.engine.domain.flow.event.ProcessCompletedEvent;
-import com.qqflow.engine.domain.flow.event.TaskAssignedEvent;
 import com.qqflow.engine.domain.flow.service.FlowEngineService;
 import com.qqflow.engine.domain.system.entity.SysDept;
 import com.qqflow.engine.domain.system.entity.SysRole;
@@ -28,29 +28,48 @@ import com.qqflow.engine.domain.system.mapper.SysDeptMapper;
 import com.qqflow.engine.domain.system.mapper.SysRoleMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import lombok.RequiredArgsConstructor;
 import lombok.SneakyThrows;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
 
 import java.time.LocalDateTime;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Deque;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_USER;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
 @Service
 @RequiredArgsConstructor
 public class FlowEngineServiceImpl implements FlowEngineService {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Pattern CONDITION_PATTERN = Pattern.compile(
+            "^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|!=|==|>|<)\\s*(.+?)\\s*$");
+    private static final int MAX_FLOW_DEPTH = 100;
 
     private final ProcessDefinitionMapper processDefinitionMapper;
     private final ProcessInstanceMapper processInstanceMapper;
     private final ApprovalTaskMapper approvalTaskMapper;
     private final ApprovalTaskAssembler approvalTaskAssembler;
+    private final ApprovalTaskHelper approvalTaskHelper;
     private final SysRoleMapper sysRoleMapper;
     private final SysUserRoleMapper sysUserRoleMapper;
     private final SysUserMapper sysUserMapper;
@@ -69,11 +88,12 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     public List<FlowNode> getNextNodes(FlowModel model, String currentNodeId, ProcessInstance instance) {
-        List<FlowEdge> edges = model.getEdges().stream()
-                .filter(e -> Objects.equals(e.getSourceNodeId(), currentNodeId))
-                .collect(Collectors.toList());
+        List<FlowEdge> edges = safeEdges(model);
         List<String> targetIds = new ArrayList<>();
         for (FlowEdge edge : edges) {
+            if (!Objects.equals(edge.getSourceNodeId(), currentNodeId)) {
+                continue;
+            }
             Object conditionObj = edge.getCondition() != null ? edge.getCondition().get("condition") : null;
             String condition = conditionObj != null ? conditionObj.toString() : null;
             if (condition == null || condition.trim().isEmpty()) {
@@ -82,31 +102,27 @@ public class FlowEngineServiceImpl implements FlowEngineService {
                 targetIds.add(edge.getTargetNodeId());
             }
         }
-        return model.getNodes().stream()
+        return safeNodes(model).stream()
                 .filter(n -> targetIds.contains(n.getId()))
                 .collect(Collectors.toList());
     }
 
     private boolean evaluateCondition(String condition, String formDataJson) {
         try {
-            Map<String, Object> formData = formDataJson != null ? OBJECT_MAPPER.readValue(formDataJson, Map.class) : Collections.emptyMap();
-            condition = condition.trim();
-            // 支持格式:field == value, field > value, field >= value, field < value, field <= value, field != value
-            String[] operators = {">=", "<=", "!=", "==", ">", "<"};
-            String foundOp = null;
-            for (String op : operators) {
-                if (condition.contains(op)) {
-                    foundOp = op;
-                    break;
-                }
+            Map<String, Object> formData = formDataJson != null
+                    ? OBJECT_MAPPER.readValue(formDataJson, Map.class)
+                    : Collections.emptyMap();
+            Matcher matcher = CONDITION_PATTERN.matcher(condition.trim());
+            if (!matcher.find()) {
+                return false;
             }
-            if (foundOp == null) return false;
-            String[] parts = condition.split(java.util.regex.Pattern.quote(foundOp), 2);
-            if (parts.length != 2) return false;
-            String field = parts[0].trim();
-            String value = parts[1].trim();
+            String field = matcher.group(1);
+            String foundOp = matcher.group(2);
+            String value = matcher.group(3).trim();
             Object actual = formData.get(field);
-            if (actual == null) return false;
+            if (actual == null) {
+                return false;
+            }
             String actualStr = actual.toString();
             // 尝试数字比较
             try {
@@ -122,10 +138,11 @@ public class FlowEngineServiceImpl implements FlowEngineService {
                     default -> false;
                 };
             } catch (NumberFormatException e) {
-                // 字符串比较
+                // 字符串比较:去除单/双引号
+                String normalizedValue = value.replaceAll("^['\"]|['\"]$", "");
                 return switch (foundOp) {
-                    case "==" -> actualStr.equals(value);
-                    case "!=" -> !actualStr.equals(value);
+                    case "==" -> actualStr.equals(normalizedValue);
+                    case "!=" -> !actualStr.equals(normalizedValue);
                     default -> false;
                 };
             }
@@ -152,7 +169,7 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         Object approverObj = props.get("approver");
         if (approverObj != null) {
             String approver = approverObj.toString();
-            return this.doCalculateAssignees("ROLE", approver, instance);
+            return this.doCalculateAssignees(ASSIGNEE_TYPE_ROLE, approver, instance);
         }
         return Collections.emptyList();
     }
@@ -176,16 +193,19 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void executeTransition(ProcessInstance instance, ApprovalTask currentTask, ApprovalAction action) {
         this.executeTransition(instance, currentTask, action, null);
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void executeTransition(ProcessInstance instance, ApprovalTask currentTask, ApprovalAction action, String comment) {
         this.executeTransition(instance, currentTask, action, comment, null);
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void executeTransition(ProcessInstance instance, ApprovalTask currentTask, ApprovalAction action, String comment, String targetNodeId) {
         switch (action) {
             case APPROVE -> this.handleApprove(instance, currentTask, comment);
@@ -196,7 +216,18 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void startInstance(ProcessInstance instance, ProcessDefinition definition) {
+        if (instance == null || instance.getId() == null) {
+            return;
+        }
+        // 防御重复启动:已存在任务则直接返回
+        long existingTaskCount = this.approvalTaskMapper.selectCount(
+                new LambdaQueryWrapper<ApprovalTask>()
+                        .eq(ApprovalTask::getInstanceId, instance.getId()));
+        if (existingTaskCount > 0) {
+            return;
+        }
         FlowModel model = this.parseModel(definition.getModelJson());
         FlowNode startNode = this.findStartNode(model);
         List<FlowNode> nextNodes = this.getNextNodes(model, startNode.getId(), instance);
@@ -215,16 +246,16 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         if ("SELF".equals(assigneeType)) {
             return Collections.singletonList(instance.getApplicantId());
         }
-        if ("ROLE".equals(assigneeType) && assigneeValue != null) {
+        if (ASSIGNEE_TYPE_ROLE.equals(assigneeType) && assigneeValue != null) {
             SysRole role = this.sysRoleMapper.selectByRoleCode(assigneeValue);
             if (role == null) {
                 return Collections.emptyList();
             }
             // 查询该角色下的所有用户
             List<SysUserRole> userRoles = this.sysUserRoleMapper.selectList(
-                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUserRole>()
+                    new LambdaQueryWrapper<SysUserRole>()
                             .eq(SysUserRole::getRoleId, role.getId()));
-            if (userRoles == null || userRoles.isEmpty()) {
+            if (CollectionUtils.isEmpty(userRoles)) {
                 return Collections.emptyList();
             }
             return userRoles.stream().map(SysUserRole::getUserId).distinct().collect(Collectors.toList());
@@ -244,21 +275,38 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     private void handleApprove(ProcessInstance instance, ApprovalTask currentTask, String comment) {
-        this.completeCurrentTask(currentTask, ApprovalResult.PASS.getCode(), comment);
+        if (instance == null || instance.getId() == null || currentTask == null || currentTask.getId() == null) {
+            throw new BusinessException("参数不能为空");
+        }
+        // 对实例加行锁,防止会签/或签并发竞态
+        this.lockInstance(instance.getId());
+
+        // 在锁内重新查询任务状态,防止重复处理
+        ApprovalTask lockedTask = this.approvalTaskMapper.selectByIdForUpdate(currentTask.getId());
+        if (lockedTask == null || !TaskStatus.PENDING.getCode().equals(lockedTask.getTaskStatus())) {
+            throw new BusinessException("任务已处理");
+        }
+
+        this.completeCurrentTask(lockedTask, ApprovalResult.PASS.getCode(), comment);
 
         FlowModel model = this.getModelByInstance(instance);
-        FlowNode currentNode = model.getNodes().stream()
-                .filter(n -> n.getId().equals(currentTask.getNodeId()))
+        FlowNode currentNode = safeNodes(model).stream()
+                .filter(n -> n.getId().equals(lockedTask.getNodeId()))
                 .findFirst()
                 .orElse(null);
-        String approveMode = currentNode != null ? this.getApproveMode(currentNode) : "or";
+        String approveMode;
+        if (currentNode == null) {
+            approveMode = "or";
+        } else {
+            approveMode = this.getApproveMode(currentNode);
+        }
 
         // 会签模式:检查同节点是否还有未处理的任务
         if ("and".equals(approveMode)) {
             long pendingCount = this.approvalTaskMapper.selectCount(
-                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ApprovalTask>()
+                    new LambdaQueryWrapper<ApprovalTask>()
                             .eq(ApprovalTask::getInstanceId, instance.getId())
-                            .eq(ApprovalTask::getNodeId, currentTask.getNodeId())
+                            .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
                             .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode()));
             if (pendingCount > 0) {
                 // 还有未处理的任务,不推进流程
@@ -267,15 +315,15 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         } else {
             // 或签模式:将同节点其他 PENDING 任务标记为 SKIPPED
             this.approvalTaskMapper.update(null,
-                    new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ApprovalTask>()
+                    new LambdaUpdateWrapper<ApprovalTask>()
                             .eq(ApprovalTask::getInstanceId, instance.getId())
-                            .eq(ApprovalTask::getNodeId, currentTask.getNodeId())
+                            .eq(ApprovalTask::getNodeId, lockedTask.getNodeId())
                             .eq(ApprovalTask::getTaskStatus, TaskStatus.PENDING.getCode())
-                            .ne(ApprovalTask::getId, currentTask.getId())
+                            .ne(ApprovalTask::getId, lockedTask.getId())
                             .set(ApprovalTask::getTaskStatus, TaskStatus.SKIPPED.getCode()));
         }
 
-        List<FlowNode> nextNodes = this.getNextNodes(model, currentTask.getNodeId(), instance);
+        List<FlowNode> nextNodes = this.getNextNodes(model, lockedTask.getNodeId(), instance);
         if (this.isEndNode(nextNodes)) {
             this.completeInstance(instance);
             return;
@@ -285,31 +333,82 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     private void handleReject(ProcessInstance instance, ApprovalTask currentTask, String comment) {
+        this.lockInstance(instance.getId());
         this.completeCurrentTask(currentTask, ApprovalResult.REJECT.getCode(), comment);
+        this.approvalTaskHelper.cancelPendingTasks(instance.getId(), LocalDateTime.now());
         this.rejectInstance(instance);
     }
 
     private void handleReturn(ProcessInstance instance, ApprovalTask currentTask, String comment, String targetNodeId) {
-        this.updateTaskStatus(currentTask, TaskStatus.RETURNED.getCode(), comment);
+        this.lockInstance(instance.getId());
         FlowModel model = this.getModelByInstance(instance);
-        FlowNode targetNode = null;
-        if (targetNodeId != null && !targetNodeId.isEmpty()) {
-            targetNode = model.getNodes().stream()
-                    .filter(n -> n.getId().equals(targetNodeId))
-                    .findFirst()
-                    .orElse(null);
-        }
-        if (targetNode == null) {
-            targetNode = this.findPreviousNode(model, currentTask.getNodeId());
-        }
+        FlowNode currentNode = safeNodes(model).stream()
+                .filter(n -> n.getId().equals(currentTask.getNodeId()))
+                .findFirst()
+                .orElse(null);
+        FlowNode targetNode = this.resolveReturnTarget(model, currentNode, targetNodeId);
         if (targetNode == null) {
             throw new BusinessException("无法找到回退目标节点");
         }
+        this.updateTaskStatus(currentTask, TaskStatus.RETURNED.getCode(), comment);
+        this.approvalTaskHelper.cancelPendingTasks(instance.getId(), LocalDateTime.now());
         List<FlowNode> returnTargets = Collections.singletonList(targetNode);
         this.createTasksForNodes(instance, returnTargets, model);
         this.updateInstanceNode(instance, returnTargets);
     }
 
+    private void lockInstance(Long instanceId) {
+        this.processInstanceMapper.selectOne(
+                Wrappers.<ProcessInstance>lambdaQuery()
+                        .eq(ProcessInstance::getId, instanceId)
+                        .last("FOR UPDATE"));
+    }
+
+    private FlowNode resolveReturnTarget(FlowModel model, FlowNode currentNode, String targetNodeId) {
+        if (currentNode == null) {
+            return null;
+        }
+        List<String> upstreamIds = this.collectUpstreamNodeIds(model, currentNode.getId());
+        if (targetNodeId != null && !targetNodeId.isEmpty()) {
+            if (!upstreamIds.contains(targetNodeId)) {
+                throw new BusinessException("回退目标节点不在当前节点上游");
+            }
+            FlowNode target = safeNodes(model).stream()
+                    .filter(n -> n.getId().equals(targetNodeId))
+                    .findFirst()
+                    .orElse(null);
+            if (target != null && NodeType.APPROVAL.getCode().equals(target.getType())) {
+                return target;
+            }
+            throw new BusinessException("回退目标节点必须是已审批节点");
+        }
+        return this.findPreviousNode(model, currentNode.getId());
+    }
+
+    private List<String> collectUpstreamNodeIds(FlowModel model, String currentNodeId) {
+        List<String> upstream = new ArrayList<>();
+        Set<String> visited = new HashSet<>();
+        Deque<String> stack = new ArrayDeque<>();
+        stack.push(currentNodeId);
+        while (!stack.isEmpty()) {
+            String nodeId = stack.pop();
+            if (!visited.add(nodeId)) {
+                continue;
+            }
+            List<String> sourceIds = safeEdges(model).stream()
+                    .filter(e -> nodeId.equals(e.getTargetNodeId()))
+                    .map(FlowEdge::getSourceNodeId)
+                    .toList();
+            for (String sourceId : sourceIds) {
+                if (!visited.contains(sourceId)) {
+                    upstream.add(sourceId);
+                    stack.push(sourceId);
+                }
+            }
+        }
+        return upstream;
+    }
+
     private FlowModel getModelByInstance(ProcessInstance instance) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
         if (definition == null) {
@@ -319,18 +418,18 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     private FlowNode findStartNode(FlowModel model) {
-        return model.getNodes().stream()
+        return safeNodes(model).stream()
                 .filter(n -> NodeType.START.getCode().equals(n.getType()))
                 .findFirst()
                 .orElseThrow(() -> new BusinessException("未找到开始节点"));
     }
 
     private FlowNode findPreviousNode(FlowModel model, String currentNodeId) {
-        List<String> sourceIds = model.getEdges().stream()
+        List<String> sourceIds = safeEdges(model).stream()
                 .filter(e -> Objects.equals(e.getTargetNodeId(), currentNodeId))
                 .map(FlowEdge::getSourceNodeId)
                 .collect(Collectors.toList());
-        return model.getNodes().stream()
+        return safeNodes(model).stream()
                 .filter(n -> sourceIds.contains(n.getId()) && !NodeType.START.getCode().equals(n.getType()))
                 .findFirst()
                 .orElse(null);
@@ -340,71 +439,136 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         return nodes.stream().anyMatch(n -> NodeType.END.getCode().equals(n.getType()));
     }
 
+    private List<FlowNode> safeNodes(FlowModel model) {
+        return model != null && model.getNodes() != null ? model.getNodes() : Collections.emptyList();
+    }
+
+    private List<FlowEdge> safeEdges(FlowModel model) {
+        return model != null && model.getEdges() != null ? model.getEdges() : Collections.emptyList();
+    }
+
     private void createTasksForNodes(ProcessInstance instance, List<FlowNode> nodes, FlowModel model) {
+        this.createTasksForNodes(instance, nodes, model, new HashSet<>(), 0);
+    }
+
+    private void createTasksForNodes(ProcessInstance instance, List<FlowNode> nodes, FlowModel model,
+                                     Set<String> visited, int depth) {
+        if (depth > MAX_FLOW_DEPTH) {
+            throw new BusinessException("流程模型存在循环或层级过深,已超过最大处理深度");
+        }
         ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
         String processName = definition != null ? definition.getProcessName() : "";
         for (FlowNode node : nodes) {
             if (NodeType.START.getCode().equals(node.getType()) || NodeType.END.getCode().equals(node.getType())) {
                 continue;
             }
+            // 幂等:已存在处理/跳过/待处理任务则不再重复创建
+            if (this.hasExistingTaskForNode(instance.getId(), node.getId())) {
+                continue;
+            }
+            if (!visited.add(node.getId())) {
+                // 已在当前执行路径中处理过,防止循环
+                continue;
+            }
             // CC节点:创建抄送任务并自动标记为已阅,同时继续推进下游
             if (NodeType.CC.getCode().equals(node.getType())) {
-                List<Long> ccAssignees = this.calculateAssignees(node, instance);
+                Map<String, Object> ccProps = node.getProperties();
+                String ccAssigneeType = ASSIGNEE_TYPE_ROLE;
+                String ccAssigneeValue = null;
+                if (ccProps != null) {
+                    Object typeObj = ccProps.get("assigneeType");
+                    Object valueObj = ccProps.get("assigneeValue");
+                    if (typeObj != null) {
+                        ccAssigneeType = typeObj.toString();
+                        ccAssigneeValue = valueObj != null ? valueObj.toString() : null;
+                    }
+                    // 旧格式:ccUsers 直接保存了 roleCode
+                    Object ccUsersObj = ccProps.get("ccUsers");
+                    if (ccAssigneeValue == null && ccUsersObj != null) {
+                        ccAssigneeValue = ccUsersObj.toString();
+                    }
+                }
+
+                List<Long> ccAssignees;
+                String ccTaskAssigneeType;
+                if (ASSIGNEE_TYPE_ROLE.equals(ccAssigneeType) && ccAssigneeValue != null) {
+                    SysRole role = this.sysRoleMapper.selectByRoleCode(ccAssigneeValue);
+                    ccAssignees = role != null ? Collections.singletonList(role.getId()) : Collections.emptyList();
+                    ccTaskAssigneeType = ASSIGNEE_TYPE_ROLE;
+                } else {
+                    ccAssignees = this.calculateAssignees(node, instance);
+                    ccTaskAssigneeType = ASSIGNEE_TYPE_USER;
+                }
                 if (ccAssignees.isEmpty()) {
                     ccAssignees = Collections.singletonList(instance.getApplicantId());
+                    ccTaskAssigneeType = ASSIGNEE_TYPE_USER;
                 }
                 for (Long assigneeId : ccAssignees) {
                     ApprovalTask ccTask = this.approvalTaskAssembler.buildNew(
                             instance.getId(), node.getId(), node.getName(),
-                            node.getType(), assigneeId, "USER", TaskStatus.PENDING.getCode()
+                            node.getType(), assigneeId, ccTaskAssigneeType, TaskStatus.PENDING.getCode()
                     );
                     this.approvalTaskMapper.insert(ccTask);
                 }
                 // 自动推进到CC节点的下游
                 List<FlowNode> ccNextNodes = this.getNextNodes(model, node.getId(), instance);
                 if (!ccNextNodes.isEmpty()) {
-                    this.createTasksForNodes(instance, ccNextNodes, model);
+                    this.createTasksForNodes(instance, ccNextNodes, model, visited, depth + 1);
                 }
                 continue;
             }
-            List<Long> assignees = this.calculateAssignees(node, instance);
-            if (assignees.isEmpty()) {
-                assignees = Collections.singletonList(instance.getApplicantId());
-            }
-            String approveMode = this.getApproveMode(node);
-            List<Long> finalAssignees = assignees;
-            if ("or".equals(approveMode) && assignees.size() > 1) {
-                finalAssignees = assignees;
-            }
             // 确定处理人类型
             Map<String, Object> nodeProps = node.getProperties();
-            String taskAssigneeType = "USER";
+            String assigneeType = ASSIGNEE_TYPE_ROLE;
+            String assigneeValue = null;
             if (nodeProps != null) {
                 Object typeObj = nodeProps.get("assigneeType");
+                Object valueObj = nodeProps.get("assigneeValue");
                 if (typeObj != null) {
-                    taskAssigneeType = typeObj.toString();
+                    assigneeType = typeObj.toString();
+                    assigneeValue = valueObj != null ? valueObj.toString() : null;
                 } else {
                     Object approverObj = nodeProps.get("approver");
                     if (approverObj != null) {
-                        taskAssigneeType = "ROLE";
+                        assigneeValue = approverObj.toString();
                     }
                 }
             }
-            // SELF/LEADER 已解析为具体用户ID,assigneeType 设为 USER
-            if ("SELF".equals(taskAssigneeType) || "LEADER".equals(taskAssigneeType)) {
-                taskAssigneeType = "USER";
+
+            List<Long> assignees;
+            String taskAssigneeType;
+            if (ASSIGNEE_TYPE_ROLE.equals(assigneeType)) {
+                // 角色审批:任务分配给角色账号,保持 assigneeType=ROLE
+                SysRole role = assigneeValue != null ? this.sysRoleMapper.selectByRoleCode(assigneeValue) : null;
+                assignees = role != null ? Collections.singletonList(role.getId()) : Collections.emptyList();
+                taskAssigneeType = ASSIGNEE_TYPE_ROLE;
+            } else {
+                // USER / SELF / LEADER 解析为具体用户
+                assignees = this.calculateAssignees(node, instance);
+                taskAssigneeType = ASSIGNEE_TYPE_USER;
             }
+            if (assignees.isEmpty()) {
+                assignees = Collections.singletonList(instance.getApplicantId());
+                taskAssigneeType = ASSIGNEE_TYPE_USER;
+            }
+
+            String approveMode = this.getApproveMode(node);
+            LocalDateTime timeoutTime = this.calculateTimeoutTime(node);
+            String timeoutAction = this.calculateTimeoutAction(node);
+            List<Long> finalAssignees = assignees;
             for (Long assigneeId : finalAssignees) {
                 ApprovalTask task = this.approvalTaskAssembler.buildNew(
                         instance.getId(), node.getId(), node.getName(),
                         node.getType(), assigneeId, taskAssigneeType, TaskStatus.PENDING.getCode()
                 );
+                task.setTimeoutTime(timeoutTime);
+                task.setTimeoutAction(timeoutAction);
                 this.approvalTaskMapper.insert(task);
             }
             // 发布任务分配通知事件
             this.eventPublisher.publishEvent(new TaskAssignedEvent(
                     this, instance.getId(), instance.getTitle(),
-                    processName, node.getName(), finalAssignees
+                    processName, node.getName(), finalAssignees, taskAssigneeType
             ));
         }
     }
@@ -419,7 +583,7 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         }
         Integer status = ProcessStatus.PENDING.getCode();
         this.processInstanceMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProcessInstance>()
+                new LambdaUpdateWrapper<ProcessInstance>()
                         .eq(ProcessInstance::getId, instance.getId())
                         .set(ProcessInstance::getCurrentNodeId, firstNode.getId())
                         .set(ProcessInstance::getStatus, status));
@@ -427,8 +591,12 @@ public class FlowEngineServiceImpl implements FlowEngineService {
 
     private void completeInstance(ProcessInstance instance) {
         this.processInstanceMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProcessInstance>()
+                new LambdaUpdateWrapper<ProcessInstance>()
                         .eq(ProcessInstance::getId, instance.getId())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.COMPLETED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.REJECTED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.TERMINATED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.REVOKED.getCode())
                         .set(ProcessInstance::getStatus, ProcessStatus.COMPLETED.getCode())
                         .set(ProcessInstance::getEndTime, LocalDateTime.now())
                         .set(ProcessInstance::getResult, ApprovalResult.PASS.getCode()));
@@ -442,8 +610,12 @@ public class FlowEngineServiceImpl implements FlowEngineService {
 
     private void rejectInstance(ProcessInstance instance) {
         this.processInstanceMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProcessInstance>()
+                new LambdaUpdateWrapper<ProcessInstance>()
                         .eq(ProcessInstance::getId, instance.getId())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.COMPLETED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.REJECTED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.TERMINATED.getCode())
+                        .ne(ProcessInstance::getStatus, ProcessStatus.REVOKED.getCode())
                         .set(ProcessInstance::getStatus, ProcessStatus.REJECTED.getCode())
                         .set(ProcessInstance::getResult, ApprovalResult.REJECT.getCode())
                         .set(ProcessInstance::getEndTime, LocalDateTime.now()));
@@ -455,22 +627,82 @@ public class FlowEngineServiceImpl implements FlowEngineService {
         ));
     }
 
+    private boolean hasExistingTaskForNode(Long instanceId, String nodeId) {
+        if (instanceId == null || nodeId == null) {
+            return false;
+        }
+        long count = this.approvalTaskMapper.selectCount(
+                new LambdaQueryWrapper<ApprovalTask>()
+                        .eq(ApprovalTask::getInstanceId, instanceId)
+                        .eq(ApprovalTask::getNodeId, nodeId)
+                        .in(ApprovalTask::getTaskStatus,
+                                TaskStatus.PENDING.getCode(),
+                                TaskStatus.HANDLED.getCode(),
+                                TaskStatus.SKIPPED.getCode()));
+        return count > 0;
+    }
+
     private void completeCurrentTask(ApprovalTask task, String result, String comment) {
-        this.approvalTaskMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ApprovalTask>()
-                        .eq(ApprovalTask::getId, task.getId())
-                        .set(ApprovalTask::getTaskStatus, TaskStatus.HANDLED.getCode())
-                        .set(ApprovalTask::getApprovalResult, result)
-                        .set(ApprovalTask::getApprovalComment, comment)
-                        .set(ApprovalTask::getHandleTime, LocalDateTime.now()));
+        task.setTaskStatus(TaskStatus.HANDLED.getCode());
+        task.setApprovalResult(result);
+        task.setApprovalComment(comment);
+        task.setHandleTime(LocalDateTime.now());
+        this.approvalTaskMapper.updateById(task);
     }
 
     private void updateTaskStatus(ApprovalTask task, Integer status, String comment) {
-        this.approvalTaskMapper.update(null,
-                new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ApprovalTask>()
-                        .eq(ApprovalTask::getId, task.getId())
-                        .set(ApprovalTask::getTaskStatus, status)
-                        .set(ApprovalTask::getApprovalComment, comment)
-                        .set(ApprovalTask::getHandleTime, LocalDateTime.now()));
+        task.setTaskStatus(status);
+        task.setApprovalComment(comment);
+        task.setHandleTime(LocalDateTime.now());
+        this.approvalTaskMapper.updateById(task);
+    }
+
+    /**
+     * 根据节点属性计算超时时间
+     *
+     * @param node 流程节点
+     * @return 超时时间,未配置或配置无效时返回 null
+     */
+    private LocalDateTime calculateTimeoutTime(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return null;
+        }
+        Object hoursObj = props.get("timeoutHours");
+        if (hoursObj == null) {
+            return null;
+        }
+        int hours;
+        try {
+            hours = Integer.parseInt(hoursObj.toString());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+        if (hours <= 0) {
+            return null;
+        }
+        return LocalDateTime.now().plusHours(hours);
+    }
+
+    /**
+     * 根据节点属性计算超时动作
+     *
+     * @param node 流程节点
+     * @return 超时动作:pass-自动通过,reject-自动驳回,remind-提醒(默认)
+     */
+    private String calculateTimeoutAction(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return "remind";
+        }
+        Object actionObj = props.get("timeoutAction");
+        if (actionObj == null) {
+            return "remind";
+        }
+        String action = actionObj.toString();
+        if ("pass".equals(action) || "reject".equals(action)) {
+            return action;
+        }
+        return "remind";
     }
 }

+ 10 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessDefinitionServiceImpl.java

@@ -15,6 +15,7 @@ import com.qqflow.engine.domain.flow.po.ProcessInstance;
 import com.qqflow.engine.domain.flow.service.ProcessDefinitionService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Arrays;
 import java.util.List;
@@ -28,6 +29,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     private final ProcessInstanceMapper processInstanceMapper;
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public Long saveDefinition(ProcessDefinition po) {
         // 检查是否已存在设计中的同编码流程(防止新建时编码冲突)
         LambdaQueryWrapper<ProcessDefinition> wrapper = new LambdaQueryWrapper<>();
@@ -43,6 +45,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public Long updateDefinition(ProcessDefinition po) {
         ProcessDefinition existing = this.processDefinitionMapper.selectById(po.getId());
         if (existing == null) {
@@ -56,6 +59,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
                     .set(ProcessDefinition::getProcessName, po.getProcessName())
                     .set(ProcessDefinition::getCategory, po.getCategory())
                     .set(ProcessDefinition::getFormId, po.getFormId())
+                    .set(ProcessDefinition::getFormSchema, po.getFormSchema())
                     .set(ProcessDefinition::getModelJson, po.getModelJson())
                     .set(ProcessDefinition::getDescription, po.getDescription()));
             return po.getId();
@@ -66,6 +70,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
             newDef.setProcessName(po.getProcessName());
             newDef.setCategory(po.getCategory());
             newDef.setFormId(po.getFormId());
+            newDef.setFormSchema(po.getFormSchema());
             newDef.setModelJson(po.getModelJson());
             newDef.setDescription(po.getDescription());
             newDef.setVersion(existing.getVersion() + 1);
@@ -77,6 +82,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void deleteDefinition(Long id) {
         ProcessDefinition existing = this.processDefinitionMapper.selectById(id);
         if (existing == null) {
@@ -122,14 +128,17 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void publish(Long id) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(id);
         this.validateDesigning(definition);
+        // 查询并停用当前启用的同编码流程
         this.disableOldVersion(definition.getProcessCode());
         this.enableDefinition(id);
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void stop(Long id) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(id);
         if (definition == null) {
@@ -145,6 +154,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void enable(Long id) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(id);
         if (definition == null) {

+ 232 - 15
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java

@@ -1,30 +1,35 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.common.util.SecurityUtils;
+import com.qqflow.engine.config.security.LoginUser;
+import com.qqflow.engine.domain.flow.assembler.ApprovalTaskDTOAssembler;
 import com.qqflow.engine.domain.flow.assembler.ProcessInstanceAssembler;
 import com.qqflow.engine.domain.flow.dto.ApprovalRecordDTO;
+import com.qqflow.engine.domain.flow.dto.AttachmentDTO;
 import com.qqflow.engine.domain.flow.dto.ApprovalTaskDTO;
 import com.qqflow.engine.domain.flow.dto.NodeProgressDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessInstanceDTO;
 import com.qqflow.engine.domain.flow.dto.ProcessProgressDTO;
 import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
+import com.qqflow.engine.domain.flow.enums.ApprovalResult;
 import com.qqflow.engine.domain.flow.enums.DefinitionStatus;
 import com.qqflow.engine.domain.flow.enums.NodeType;
 import com.qqflow.engine.domain.flow.enums.ProcessStatus;
 import com.qqflow.engine.domain.flow.enums.TaskStatus;
 import com.qqflow.engine.domain.flow.mapper.ApprovalRecordMapper;
 import com.qqflow.engine.domain.flow.mapper.ApprovalTaskMapper;
+import com.qqflow.engine.domain.flow.mapper.AttachmentMapper;
 import com.qqflow.engine.domain.flow.mapper.ProcessDefinitionMapper;
 import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
 import com.qqflow.engine.domain.flow.model.FlowModel;
 import com.qqflow.engine.domain.flow.model.FlowNode;
 import com.qqflow.engine.domain.flow.po.ApprovalRecord;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import com.qqflow.engine.domain.flow.po.Attachment;
 import com.qqflow.engine.domain.flow.po.ProcessDefinition;
 import com.qqflow.engine.domain.flow.po.ProcessInstance;
 import com.qqflow.engine.domain.flow.service.FlowEngineService;
@@ -32,15 +37,28 @@ import com.qqflow.engine.domain.flow.service.ProcessInstanceService;
 import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.mapper.SysUserMapper;
 import lombok.RequiredArgsConstructor;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.stereotype.Service;
-
+import org.springframework.transaction.annotation.Transactional;
+import java.io.File;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Comparator;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.ROLE_FLOW_MANAGER;
+import static com.qqflow.engine.common.constant.SecurityConstants.ROLE_SUPER_ADMIN;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
 @Service
 @RequiredArgsConstructor
 public class ProcessInstanceServiceImpl implements ProcessInstanceService {
@@ -48,18 +66,26 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     private final ProcessInstanceMapper processInstanceMapper;
     private final ProcessDefinitionMapper processDefinitionMapper;
     private final ProcessInstanceAssembler processInstanceAssembler;
+    private final ApprovalTaskDTOAssembler approvalTaskDTOAssembler;
+    private final ApprovalTaskHelper approvalTaskHelper;
     private final FlowEngineService flowEngineService;
     private final ApprovalTaskMapper approvalTaskMapper;
     private final ApprovalRecordMapper approvalRecordMapper;
+    private final AttachmentMapper attachmentMapper;
+    private final ObjectMapper objectMapper;
     private final SysUserMapper sysUserMapper;
 
     @Override
+    @Transactional
     public Long startProcess(StartProcessDTO dto) {
         ProcessDefinition definition = this.getEnabledDefinition(dto.getProcessDefinitionId());
         ProcessInstance instance = this.buildInstance(dto, definition);
         instance.setAttachmentUrls(dto.getAttachmentUrls());
         this.processInstanceMapper.insert(instance);
         this.flowEngineService.startInstance(instance, definition);
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        this.saveAttachments(instance.getId(), null, null, "start", "发起",
+                dto.getAttachmentUrls(), loginUser);
         return instance.getId();
     }
 
@@ -67,9 +93,7 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     public PageResult<ProcessInstanceDTO> list(Long applicantId, Integer status, Integer pageNum, Integer pageSize) {
         Page<ProcessInstance> page = new Page<>(pageNum, pageSize);
         this.processInstanceMapper.selectInstanceList(page, applicantId, status);
-        List<ProcessInstanceDTO> records = page.getRecords().stream()
-                .map(ProcessInstanceDTO::of)
-                .collect(Collectors.toList());
+        List<ProcessInstanceDTO> records = this.enrichInstanceDtos(page.getRecords());
         return PageResult.of(page.getTotal(), records);
     }
 
@@ -83,6 +107,24 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     }
 
     @Override
+    @Transactional
+    public void delete(Long id) {
+        Long operatorId = SecurityUtils.getUserId();
+        ProcessInstance instance = this.processInstanceMapper.selectById(id);
+        if (instance == null) {
+            throw new BusinessException("流程实例不存在");
+        }
+        if (!instance.getApplicantId().equals(operatorId)) {
+            throw new BusinessException("无权删除他人发起的流程");
+        }
+        if (!ProcessStatus.REVOKED.getCode().equals(instance.getStatus())) {
+            throw new BusinessException("只有已撤回的流程才能删除");
+        }
+        this.processInstanceMapper.deleteById(id);
+    }
+
+    @Override
+    @Transactional
     public void revoke(Long id) {
         ProcessInstance instance = this.processInstanceMapper.selectById(id);
         this.validateRevocable(instance);
@@ -90,10 +132,13 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
         if (instance != null && !instance.getApplicantId().equals(operatorId)) {
             throw new BusinessException("无权撤回他人发起的流程");
         }
+        LocalDateTime now = LocalDateTime.now();
         this.processInstanceMapper.update(null, Wrappers.<ProcessInstance>lambdaUpdate()
                 .eq(ProcessInstance::getId, id)
                 .set(ProcessInstance::getStatus, ProcessStatus.REVOKED.getCode())
-                .set(ProcessInstance::getEndTime, LocalDateTime.now()));
+                .set(ProcessInstance::getResult, ApprovalResult.REVOKE.getCode())
+                .set(ProcessInstance::getEndTime, now));
+        this.approvalTaskHelper.cancelPendingTasks(id, now);
     }
 
     @Override
@@ -102,6 +147,7 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
         if (instance == null) {
             throw new BusinessException("流程实例不存在");
         }
+        this.checkInstanceVisibility(instance);
         ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
         if (definition == null) {
             throw new BusinessException("流程定义不存在");
@@ -134,7 +180,9 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
             List<ApprovalTask> nodeTasks = tasks.stream()
                     .filter(t -> t.getNodeId().equals(node.getId()))
                     .collect(Collectors.toList());
-            dto.setTasks(nodeTasks.stream().map(ApprovalTaskDTO::of).collect(Collectors.toList()));
+            List<ApprovalTaskDTO> nodeTaskDtos = nodeTasks.stream().map(ApprovalTaskDTO::of).collect(Collectors.toList());
+            this.approvalTaskDTOAssembler.fillAssigneeName(nodeTaskDtos);
+            dto.setTasks(nodeTaskDtos);
 
             boolean hasHandled = nodeTasks.stream().anyMatch(t -> TaskStatus.HANDLED.getCode().equals(t.getTaskStatus()));
             boolean hasPending = nodeTasks.stream().anyMatch(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus()));
@@ -152,9 +200,11 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
             }
 
             // 判断当前用户是否需要处理该节点
+            String currentUserType = getCurrentUserType();
             boolean isMyTurn = nodeTasks.stream()
                     .anyMatch(t -> TaskStatus.PENDING.getCode().equals(t.getTaskStatus())
-                            && currentUserId.equals(t.getAssigneeId()));
+                            && currentUserId.equals(t.getAssigneeId())
+                            && (USER_TYPE_ROLE.equals(currentUserType) ? ASSIGNEE_TYPE_ROLE.equals(t.getAssigneeType()) : !ASSIGNEE_TYPE_ROLE.equals(t.getAssigneeType())));
             dto.setIsMyTurn(isMyTurn);
 
             nodeProgressList.add(dto);
@@ -178,15 +228,58 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     }
 
     @Override
-    public PageResult<ProcessInstanceDTO> participatedList(Long userId, Integer pageNum, Integer pageSize) {
+    public PageResult<ProcessInstanceDTO> participatedList(Long userId, String userType, Integer pageNum, Integer pageSize) {
         Page<ProcessInstance> page = new Page<>(pageNum, pageSize);
-        this.processInstanceMapper.selectParticipatedList(page, userId);
-        List<ProcessInstanceDTO> records = page.getRecords().stream()
-                .map(ProcessInstanceDTO::of)
-                .collect(Collectors.toList());
+        this.processInstanceMapper.selectParticipatedList(page, userId, userType);
+        List<ProcessInstanceDTO> records = this.enrichInstanceDtos(page.getRecords());
         return PageResult.of(page.getTotal(), records);
     }
 
+    private List<ProcessInstanceDTO> enrichInstanceDtos(List<ProcessInstance> records) {
+        if (records.isEmpty()) {
+            return new ArrayList<>();
+        }
+        Map<Long, Map<String, String>> definitionNodeNameMap = this.buildNodeNameMap(records);
+        return records.stream()
+                .map(po -> {
+                    ProcessInstanceDTO dto = ProcessInstanceDTO.of(po);
+                    if (po.getCurrentNodeId() != null && po.getProcessDefinitionId() != null) {
+                        Map<String, String> nodeMap = definitionNodeNameMap.get(po.getProcessDefinitionId());
+                        if (nodeMap != null) {
+                            dto.setCurrentNodeName(nodeMap.get(po.getCurrentNodeId()));
+                        }
+                    }
+                    return dto;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private Map<Long, Map<String, String>> buildNodeNameMap(List<ProcessInstance> records) {
+        Set<Long> definitionIds = records.stream()
+                .map(ProcessInstance::getProcessDefinitionId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, Map<String, String>> result = new HashMap<>();
+        if (definitionIds.isEmpty()) {
+            return result;
+        }
+        List<ProcessDefinition> definitions = this.processDefinitionMapper.selectBatchIds(definitionIds);
+        for (ProcessDefinition definition : definitions) {
+            if (definition == null || definition.getModelJson() == null) {
+                continue;
+            }
+            FlowModel model = this.flowEngineService.parseModel(definition.getModelJson());
+            if (model == null || model.getNodes() == null) {
+                continue;
+            }
+            Map<String, String> nodeMap = model.getNodes().stream()
+                    .filter(node -> node.getId() != null)
+                    .collect(Collectors.toMap(FlowNode::getId, node -> node.getName() != null ? node.getName() : node.getId(), (a, b) -> a));
+            result.put(definition.getId(), nodeMap);
+        }
+        return result;
+    }
+
     private ProcessDefinition getEnabledDefinition(Long processDefinitionId) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(processDefinitionId);
         if (definition == null || !DefinitionStatus.ENABLED.getCode().equals(definition.getStatus())) {
@@ -213,7 +306,9 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     }
 
     private String generateInstanceNo() {
-        return "PI" + System.currentTimeMillis();
+        return "PI" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
+                .format(LocalDateTime.now())
+                + String.format("%04d", (int) (Math.random() * 10000));
     }
 
     private void validateRevocable(ProcessInstance instance) {
@@ -225,4 +320,126 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
             throw new BusinessException("当前状态不可撤回");
         }
     }
+
+    private void checkInstanceVisibility(ProcessInstance instance) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null) {
+            throw new BusinessException("未登录");
+        }
+        Long userId = loginUser.getUserId();
+        String userType = loginUser.getUserType();
+        // 管理员可见所有
+        if (!USER_TYPE_ROLE.equals(userType) && loginUser.getRoles() != null
+                && (loginUser.getRoles().contains(ROLE_SUPER_ADMIN) || loginUser.getRoles().contains(ROLE_FLOW_MANAGER))) {
+            return;
+        }
+        // 申请人可见
+        if (userId.equals(instance.getApplicantId()) && !USER_TYPE_ROLE.equals(userType)) {
+            return;
+        }
+        // 任务处理人可见
+        List<ApprovalTask> tasks = this.approvalTaskMapper.selectByInstanceId(instance.getId());
+        for (ApprovalTask task : tasks) {
+            if (userId.equals(task.getAssigneeId())) {
+                if (USER_TYPE_ROLE.equals(userType) && ASSIGNEE_TYPE_ROLE.equals(task.getAssigneeType())) {
+                    return;
+                }
+                if (!USER_TYPE_ROLE.equals(userType) && !ASSIGNEE_TYPE_ROLE.equals(task.getAssigneeType())) {
+                    return;
+                }
+            }
+        }
+        // 审批记录操作人可见
+        List<ApprovalRecord> records = this.approvalRecordMapper.selectRecordListByInstanceId(instance.getId());
+        for (ApprovalRecord record : records) {
+            if (userId.equals(record.getOperatorId()) && !USER_TYPE_ROLE.equals(userType)) {
+                return;
+            }
+        }
+        throw new BusinessException("无权查看该流程实例");
+    }
+
+    private String getCurrentUserType() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        return loginUser != null ? loginUser.getUserType() : USER_TYPE_SYSTEM;
+    }
+
+    @Override
+    public List<AttachmentDTO> listAttachments(Long instanceId) {
+        ProcessInstance instance = this.processInstanceMapper.selectById(instanceId);
+        if (instance == null) {
+            throw new BusinessException("流程实例不存在");
+        }
+        this.checkInstanceVisibility(instance);
+        List<Attachment> attachments = this.attachmentMapper.selectList(
+                Wrappers.<Attachment>lambdaQuery()
+                        .eq(Attachment::getInstanceId, instanceId)
+                        .orderByAsc(Attachment::getCreateTime)
+        );
+        return attachments.stream()
+                .map(AttachmentDTO::of)
+                .collect(Collectors.toList());
+    }
+
+    private void saveAttachments(Long instanceId, Long taskId, Long recordId,
+                                 String nodeId, String nodeName,
+                                 String attachmentUrls, LoginUser loginUser) {
+        List<String> urls = this.parseAttachmentUrls(attachmentUrls);
+        if (urls.isEmpty()) {
+            return;
+        }
+        for (String url : urls) {
+            Attachment attachment = this.buildAttachment(instanceId, taskId, recordId,
+                    nodeId, nodeName, url, loginUser);
+            this.attachmentMapper.insert(attachment);
+        }
+    }
+
+    private Attachment buildAttachment(Long instanceId, Long taskId, Long recordId,
+                                       String nodeId, String nodeName,
+                                       String url, LoginUser loginUser) {
+        Attachment attachment = new Attachment();
+        attachment.setInstanceId(instanceId);
+        attachment.setTaskId(taskId);
+        attachment.setRecordId(recordId);
+        attachment.setNodeId(nodeId);
+        attachment.setNodeName(nodeName);
+        attachment.setFileUrl(url);
+        String fileName = url;
+        int lastSlash = url.lastIndexOf('/');
+        if (lastSlash >= 0 && lastSlash < url.length() - 1) {
+            fileName = url.substring(lastSlash + 1);
+        }
+        attachment.setFileName(fileName);
+        try {
+            String path = url.startsWith("/uploads/") ? url.substring(1) : url;
+            File file = new File(path);
+            if (file.exists() && file.isFile()) {
+                attachment.setFileSize(file.length());
+            }
+        } catch (Exception ignored) {
+            // 无法读取文件大小时忽略
+        }
+        if (loginUser != null) {
+            attachment.setUploaderId(loginUser.getUserId());
+            attachment.setUploaderName(loginUser.getRealName());
+            attachment.setUploaderType(loginUser.getUserType());
+        }
+        return attachment;
+    }
+
+    private List<String> parseAttachmentUrls(String attachmentUrls) {
+        if (attachmentUrls == null || attachmentUrls.isBlank()) {
+            return Collections.emptyList();
+        }
+        String trimmed = attachmentUrls.trim();
+        if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+            try {
+                return this.objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
+            } catch (Exception ignored) {
+                // 解析失败时降级为字符串处理
+            }
+        }
+        return List.of(trimmed);
+    }
 }

+ 120 - 20
src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java

@@ -1,8 +1,13 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
+import com.qqflow.engine.domain.flow.po.ApprovalTask;
 import com.qqflow.engine.domain.flow.service.NotificationService;
+import com.qqflow.engine.domain.system.dto.WeComConfigDTO;
+import com.qqflow.engine.domain.system.entity.SysRole;
 import com.qqflow.engine.domain.system.entity.SysUser;
+import com.qqflow.engine.domain.system.mapper.SysRoleMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserMapper;
+import com.qqflow.engine.domain.system.service.SysNotificationConfigService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -12,14 +17,15 @@ import java.util.List;
 /**
  * 企业微信通知服务(预留实现)
  * <p>
- * TODO: 后续对接企业微信 API,实现以下功能:
- * 1. 通过企业微信应用消息推送审批通知
- * 2. 支持@用户提醒
- * 3. 支持消息模板(审批申请、审批结果、流程结束)
+ * 本服务仅负责收集通知事件、读取企微配置、定位接收人的企微账号,并通过日志输出待发送内容。
+ * 实际的企业微信应用消息推送由外部对接系统完成,对接方可选择以下方式消费:
+ * 1. 监听本系统发布的 TaskAssignedEvent / TaskCompletedEvent / ProcessCompletedEvent;
+ * 2. 调用本系统预留的查询接口(如 {@link com.qqflow.engine.domain.system.service.SysNotificationConfigService#getWeComConfig()})读取配置;
+ * 3. 读取 sys_user.wecom_user_id 或 sys_role.wecom_user_id 获取接收人企微账号。
  * <p>
  * 对接时需要:
- * - 企业微信 corpId, agentId, secret
- * - 用户企微账号与系统用户的映射(建议通过手机号匹配
+ * - 企业微信 corpId, agentId, secret(存储在 sys_notification_config 表)
+ * - 用户/角色企微账号映射(sys_user.wecom_user_id / sys_role.wecom_user_id
  * - 调用企微消息推送 API: POST https://qyapi.weixin.qq.com/cgi-bin/message/send
  */
 @Slf4j
@@ -27,33 +33,127 @@ import java.util.List;
 @RequiredArgsConstructor
 public class WeComNotificationService implements NotificationService {
 
+    private static final String ASSIGNEE_TYPE_ROLE = "ROLE";
+
     private final SysUserMapper sysUserMapper;
+    private final SysRoleMapper sysRoleMapper;
+    private final SysNotificationConfigService sysNotificationConfigService;
 
     @Override
-    public void notifyTaskAssigned(List<Long> assigneeIds, String processName, String instanceTitle, String nodeName) {
-        for (Long userId : assigneeIds) {
-            SysUser user = sysUserMapper.selectById(userId);
-            String userName = user != null ? user.getRealName() : String.valueOf(userId);
-            log.info("[企微通知-预留] 待办提醒 | 接收人:{} | 流程:{} | 标题:{} | 节点:{}",
-                    userName, processName, instanceTitle, nodeName);
+    public void notifyTaskAssigned(List<Long> assigneeIds, String assigneeType, String processName, String instanceTitle, String nodeName) {
+        WeComConfigDTO config = this.sysNotificationConfigService.getWeComConfig();
+        if (!isEnabled(config)) {
+            log.debug("[企微通知-预留] 企业微信通知未启用,跳过待办提醒");
+            return;
+        }
+        for (Long assigneeId : assigneeIds) {
+            String wecomUserId = resolveWeComUserId(assigneeId, assigneeType);
+            String receiverName = resolveReceiverName(assigneeId, assigneeType);
+            if (!hasReminderEnabled(assigneeId, assigneeType) || wecomUserId == null) {
+                continue;
+            }
+            log.info("[企微通知-预留] 待办提醒 | 接收人:{} | 企微账号:{} | 流程:{} | 标题:{} | 节点:{} | corpId:{} | agentId:{}",
+                    receiverName, wecomUserId, processName, instanceTitle, nodeName,
+                    mask(config.getCorpId()), mask(config.getAgentId()));
+            // TODO: 生产环境由企业微信对接后端调用企微 API 发送应用消息
         }
-        // TODO: 调用企微 API 发送应用消息
     }
 
     @Override
     public void notifyTaskCompleted(Long instanceId, String processName, String instanceTitle,
                                     String nodeName, String operatorName, String action) {
-        log.info("[企微通知-预留] 审批结果 | 实例ID:{} | 流程:{} | 标题:{} | 节点:{} | 操作人:{} | 操作:{}",
-                instanceId, processName, instanceTitle, nodeName, operatorName, action);
-        // TODO: 调用企微 API 发送审批结果通知(通知发起人或相关人)
+        WeComConfigDTO config = this.sysNotificationConfigService.getWeComConfig();
+        if (!isEnabled(config)) {
+            log.debug("[企微通知-预留] 企业微信通知未启用,跳过审批结果通知");
+            return;
+        }
+        log.info("[企微通知-预留] 审批结果 | 实例ID:{} | 流程:{} | 标题:{} | 节点:{} | 操作人:{} | 操作:{} | corpId:{} | agentId:{}",
+                instanceId, processName, instanceTitle, nodeName, operatorName, action,
+                mask(config.getCorpId()), mask(config.getAgentId()));
+        // TODO: 生产环境由企业微信对接后端调用企微 API 发送审批结果通知(通知发起人或相关人)
     }
 
     @Override
     public void notifyProcessCompleted(Long applicantId, String processName, String instanceTitle, String result) {
+        WeComConfigDTO config = this.sysNotificationConfigService.getWeComConfig();
+        if (!isEnabled(config)) {
+            log.debug("[企微通知-预留] 企业微信通知未启用,跳过流程结束通知");
+            return;
+        }
         SysUser applicant = sysUserMapper.selectById(applicantId);
-        String applicantName = applicant != null ? applicant.getRealName() : String.valueOf(applicantId);
-        log.info("[企微通知-预留] 流程结束 | 接收人:{} | 流程:{} | 标题:{} | 结果:{}",
-                applicantName, processName, instanceTitle, result);
-        // TODO: 调用企微 API 发送流程结束通知
+        if (applicant == null || !isUserRemindEnabled(applicant)) {
+            return;
+        }
+        log.info("[企微通知-预留] 流程结束 | 接收人:{} | 企微账号:{} | 流程:{} | 标题:{} | 结果:{} | corpId:{} | agentId:{}",
+                applicant.getRealName(), applicant.getWecomUserId(), processName, instanceTitle, result,
+                mask(config.getCorpId()), mask(config.getAgentId()));
+        // TODO: 生产环境由企业微信对接后端调用企微 API 发送流程结束通知
+    }
+
+    @Override
+    public void notifyTaskOverdue(ApprovalTask task, String processName, String instanceTitle, String nodeName, boolean overdue) {
+        WeComConfigDTO config = this.sysNotificationConfigService.getWeComConfig();
+        if (!isEnabled(config)) {
+            log.debug("[企微通知-预留] 企业微信通知未启用,跳过超时催办通知");
+            return;
+        }
+        Long assigneeId = task.getAssigneeId();
+        String assigneeType = task.getAssigneeType();
+        String wecomUserId = resolveWeComUserId(assigneeId, assigneeType);
+        String receiverName = resolveReceiverName(assigneeId, assigneeType);
+        if (!hasReminderEnabled(assigneeId, assigneeType) || wecomUserId == null) {
+            return;
+        }
+        String type = overdue ? "已超时催办" : "即将超时提醒";
+        log.info("[企微通知-预留] {} | 接收人:{} | 企微账号:{} | 流程:{} | 标题:{} | 节点:{} | corpId:{} | agentId:{}",
+                type, receiverName, wecomUserId, processName, instanceTitle, nodeName,
+                mask(config.getCorpId()), mask(config.getAgentId()));
+        // TODO: 生产环境由企业微信对接后端调用企微 API 发送超时/催办通知
+    }
+
+    private String resolveWeComUserId(Long assigneeId, String assigneeType) {
+        if (ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(assigneeType)) {
+            SysRole role = sysRoleMapper.selectById(assigneeId);
+            return role != null ? role.getWecomUserId() : null;
+        }
+        SysUser user = sysUserMapper.selectById(assigneeId);
+        return user != null ? user.getWecomUserId() : null;
+    }
+
+    private String resolveReceiverName(Long assigneeId, String assigneeType) {
+        if (ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(assigneeType)) {
+            SysRole role = sysRoleMapper.selectById(assigneeId);
+            return role != null ? role.getRoleName() : "";
+        }
+        SysUser user = sysUserMapper.selectById(assigneeId);
+        return user != null ? user.getRealName() : "";
+    }
+
+    private boolean hasReminderEnabled(Long assigneeId, String assigneeType) {
+        if (ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(assigneeType)) {
+            SysRole role = sysRoleMapper.selectById(assigneeId);
+            return role != null && isRoleRemindEnabled(role);
+        }
+        SysUser user = sysUserMapper.selectById(assigneeId);
+        return user != null && isUserRemindEnabled(user);
+    }
+
+    private boolean isEnabled(WeComConfigDTO config) {
+        return config != null && config.getEnabled() != null && config.getEnabled() == 1;
+    }
+
+    private boolean isUserRemindEnabled(SysUser user) {
+        return user.getWecomRemindEnabled() == null || user.getWecomRemindEnabled() == 1;
+    }
+
+    private boolean isRoleRemindEnabled(SysRole role) {
+        return role.getWecomRemindEnabled() == null || role.getWecomRemindEnabled() == 1;
+    }
+
+    private String mask(String value) {
+        if (value == null || value.length() <= 4) {
+            return value;
+        }
+        return value.substring(0, 2) + "****" + value.substring(value.length() - 2);
     }
 }

+ 0 - 68
src/main/java/com/qqflow/engine/domain/flow/statemachine/ProcessInstanceStateMachineConfig.java

@@ -1,68 +0,0 @@
-package com.qqflow.engine.domain.flow.statemachine;
-
-import com.qqflow.engine.domain.flow.enums.ProcessEvent;
-import com.qqflow.engine.domain.flow.enums.ProcessStatus;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.statemachine.config.EnableStateMachineFactory;
-import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
-import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
-import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
-
-import java.util.EnumSet;
-
-@Configuration
-@EnableStateMachineFactory
-public class ProcessInstanceStateMachineConfig extends StateMachineConfigurerAdapter<ProcessStatus, ProcessEvent> {
-
-    @Override
-    public void configure(StateMachineStateConfigurer<ProcessStatus, ProcessEvent> states) throws Exception {
-        states.withStates()
-                .initial(ProcessStatus.PENDING_RECEIVE)
-                .states(EnumSet.allOf(ProcessStatus.class));
-    }
-
-    @Override
-    public void configure(StateMachineTransitionConfigurer<ProcessStatus, ProcessEvent> transitions) throws Exception {
-        this.configureActiveTransitions(transitions);
-        this.configureTerminalTransitions(transitions);
-    }
-
-    private void configureActiveTransitions(StateMachineTransitionConfigurer<ProcessStatus, ProcessEvent> transitions) throws Exception {
-        transitions
-                .withExternal()
-                .source(ProcessStatus.PENDING_RECEIVE).target(ProcessStatus.PENDING).event(ProcessEvent.RECEIVE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING).target(ProcessStatus.APPROVED).event(ProcessEvent.APPROVE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING).target(ProcessStatus.REJECTED).event(ProcessEvent.REJECT)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING).target(ProcessStatus.RETURNED).event(ProcessEvent.RETURN)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.RETURNED).target(ProcessStatus.PENDING).event(ProcessEvent.RECEIVE);
-    }
-
-    private void configureTerminalTransitions(StateMachineTransitionConfigurer<ProcessStatus, ProcessEvent> transitions) throws Exception {
-        transitions
-                .withExternal()
-                .source(ProcessStatus.APPROVED).target(ProcessStatus.COMPLETED).event(ProcessEvent.COMPLETE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING_RECEIVE).target(ProcessStatus.REVOKED).event(ProcessEvent.REVOKE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING).target(ProcessStatus.REVOKED).event(ProcessEvent.REVOKE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.RETURNED).target(ProcessStatus.REVOKED).event(ProcessEvent.REVOKE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING_RECEIVE).target(ProcessStatus.TERMINATED).event(ProcessEvent.TERMINATE)
-                .and()
-                .withExternal()
-                .source(ProcessStatus.PENDING).target(ProcessStatus.TERMINATED).event(ProcessEvent.TERMINATE);
-    }
-}

+ 26 - 6
src/main/java/com/qqflow/engine/domain/system/controller/AuthController.java

@@ -4,10 +4,11 @@ import com.qqflow.engine.common.Result;
 import com.qqflow.engine.common.util.SecurityUtils;
 import com.qqflow.engine.config.security.LoginUser;
 import com.qqflow.engine.domain.system.assembler.UserAssembler;
+import com.qqflow.engine.domain.system.dto.ChangePasswordDTO;
 import com.qqflow.engine.domain.system.dto.LoginDTO;
 import com.qqflow.engine.domain.system.dto.UserDTO;
-import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.entity.SysRole;
+import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.mapper.SysRoleMapper;
 import com.qqflow.engine.domain.system.service.RoleAuthService;
 import com.qqflow.engine.domain.system.service.SysUserService;
@@ -16,11 +17,19 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
 import java.util.HashMap;
 import java.util.Map;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.LOGIN_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_ROLE;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
 @Tag(name = "认证管理")
 @RestController
 @RequestMapping("/auth")
@@ -37,7 +46,7 @@ public class AuthController {
     @PostMapping("/login")
     public Result<Map<String, String>> login(@Valid @RequestBody LoginDTO loginDTO) {
         String token;
-        if ("ROLE".equals(loginDTO.getLoginType())) {
+        if (LOGIN_TYPE_ROLE.equals(loginDTO.getLoginType())) {
             token = roleAuthService.login(loginDTO);
         } else {
             token = sysUserService.login(loginDTO);
@@ -65,6 +74,17 @@ public class AuthController {
         return Result.ok(map);
     }
 
+    @Operation(summary = "修改密码")
+    @PostMapping("/change-password")
+    public Result<Void> changePassword(@Valid @RequestBody ChangePasswordDTO dto) {
+        Long userId = SecurityUtils.getUserId();
+        if (userId == null) {
+            return Result.error(401, "未登录");
+        }
+        sysUserService.changePassword(userId, dto.getOldPassword(), dto.getNewPassword());
+        return Result.ok();
+    }
+
     @Operation(summary = "获取当前登录用户信息")
     @GetMapping("/info")
     public Result<UserDTO> info() {
@@ -72,7 +92,7 @@ public class AuthController {
         if (loginUser == null) {
             return Result.error(401, "未登录");
         }
-        if ("ROLE".equals(loginUser.getUserType())) {
+        if (USER_TYPE_ROLE.equals(loginUser.getUserType())) {
             SysRole role = sysRoleMapper.selectById(loginUser.getUserId());
             if (role == null) {
                 return Result.error(401, "用户不存在");
@@ -85,13 +105,13 @@ public class AuthController {
             dto.setStatus(role.getStatus());
             dto.setCreateTime(role.getCreateTime());
             dto.setRoles(loginUser.getRoles());
-            dto.setUserType("ROLE");
+            dto.setUserType(USER_TYPE_ROLE);
             return Result.ok(dto);
         }
         SysUser user = sysUserService.getByUsername(loginUser.getUsername());
         UserDTO dto = UserAssembler.toDTO(user);
         if (dto != null) {
-            dto.setUserType("SYSTEM");
+            dto.setUserType(USER_TYPE_SYSTEM);
         }
         return Result.ok(dto);
     }

+ 13 - 1
src/main/java/com/qqflow/engine/domain/system/controller/SysDeptController.java

@@ -8,13 +8,22 @@ import com.qqflow.engine.domain.system.service.SysDeptService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
 
 @Tag(name = "部门管理")
 @RestController
 @RequestMapping("/system/dept")
+@PreAuthorize("hasAnyRole('super_admin','flow_manager')")
 public class SysDeptController {
 
     @Resource
@@ -42,6 +51,7 @@ public class SysDeptController {
 
     @Operation(summary = "新增部门")
     @PostMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> add(@RequestBody SysDept dept) {
         sysDeptService.addDept(dept);
         return Result.ok();
@@ -49,6 +59,7 @@ public class SysDeptController {
 
     @Operation(summary = "修改部门")
     @PutMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> update(@RequestBody SysDept dept) {
         sysDeptService.updateDept(dept);
         return Result.ok();
@@ -56,6 +67,7 @@ public class SysDeptController {
 
     @Operation(summary = "删除部门")
     @DeleteMapping("/{id}")
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> delete(@PathVariable Long id) {
         sysDeptService.removeDept(id);
         return Result.ok();

+ 57 - 0
src/main/java/com/qqflow/engine/domain/system/controller/SysNotificationConfigController.java

@@ -0,0 +1,57 @@
+package com.qqflow.engine.domain.system.controller;
+
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.domain.system.dto.WeComConfigDTO;
+import com.qqflow.engine.domain.system.service.SysNotificationConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 通知配置管理
+ * <p>
+ * 当前仅提供企业微信配置的读取与保存接口,用于前端配置页面。
+ * 实际的企业微信消息推送由外部对接系统完成,本系统不直接调用企微 API。
+ * </p>
+ */
+@RestController
+@RequestMapping("/system/notification-config")
+@RequiredArgsConstructor
+@Tag(name = "通知配置管理")
+public class SysNotificationConfigController {
+
+    private final SysNotificationConfigService sysNotificationConfigService;
+
+    /**
+     * 获取企业微信配置
+     * <p>
+     * TODO: 生产环境由企业微信对接后端读取该配置,用于构建企微应用消息。
+     * </p>
+     */
+    @GetMapping("/wecom")
+    @Operation(summary = "获取企业微信配置")
+    @PreAuthorize("hasAnyRole('super_admin','flow_manager')")
+    public Result<WeComConfigDTO> getWeComConfig() {
+        return Result.ok(this.sysNotificationConfigService.getWeComConfig());
+    }
+
+    /**
+     * 保存企业微信配置
+     * <p>
+     * TODO: 保存后如需同步到企微对接后端,可在此扩展消息通知或事件。
+     * </p>
+     */
+    @PutMapping("/wecom")
+    @Operation(summary = "保存企业微信配置")
+    @PreAuthorize("hasRole('super_admin')")
+    public Result<Void> saveWeComConfig(@RequestBody WeComConfigDTO dto) {
+        this.sysNotificationConfigService.saveWeComConfig(dto);
+        return Result.ok();
+    }
+}

+ 23 - 1
src/main/java/com/qqflow/engine/domain/system/controller/SysRoleController.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.Result;
 import com.qqflow.engine.domain.system.assembler.RoleAssembler;
+import com.qqflow.engine.domain.system.dto.BindWeComDTO;
 import com.qqflow.engine.domain.system.dto.RoleDTO;
 import com.qqflow.engine.domain.system.entity.SysDept;
 import com.qqflow.engine.domain.system.entity.SysRole;
@@ -12,7 +13,16 @@ import com.qqflow.engine.domain.system.service.SysRoleService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+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;
 import java.util.Map;
@@ -21,6 +31,7 @@ import java.util.stream.Collectors;
 @Tag(name = "角色管理")
 @RestController
 @RequestMapping("/system/role")
+@PreAuthorize("hasAnyRole('super_admin','flow_manager')")
 public class SysRoleController {
 
     @Resource
@@ -76,6 +87,7 @@ public class SysRoleController {
 
     @Operation(summary = "新增角色")
     @PostMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> add(@RequestBody SysRole role) {
         sysRoleService.addRole(role);
         return Result.ok();
@@ -83,6 +95,7 @@ public class SysRoleController {
 
     @Operation(summary = "修改角色")
     @PutMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> update(@RequestBody SysRole role) {
         sysRoleService.updateRole(role);
         return Result.ok();
@@ -90,8 +103,17 @@ public class SysRoleController {
 
     @Operation(summary = "删除角色")
     @DeleteMapping("/{id}")
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> delete(@PathVariable Long id) {
         sysRoleService.removeRole(id);
         return Result.ok();
     }
+
+    @Operation(summary = "绑定/解绑角色企业微信账号")
+    @PutMapping("/{id}/wecom")
+    @PreAuthorize("hasRole('super_admin')")
+    public Result<Void> bindWeCom(@PathVariable Long id, @RequestBody BindWeComDTO dto) {
+        sysRoleService.bindWeCom(id, dto.getWecomUserId(), dto.getWecomRemindEnabled());
+        return Result.ok();
+    }
 }

+ 29 - 1
src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java

@@ -4,17 +4,28 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.qqflow.engine.common.PageResult;
 import com.qqflow.engine.common.Result;
 import com.qqflow.engine.domain.system.assembler.UserAssembler;
+import com.qqflow.engine.domain.system.dto.BindWeComDTO;
 import com.qqflow.engine.domain.system.dto.UserDTO;
 import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.service.SysUserService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
 
 @Tag(name = "用户管理")
 @RestController
 @RequestMapping("/system/user")
+@PreAuthorize("hasAnyRole('super_admin','flow_manager')")
 public class SysUserController {
 
     @Resource
@@ -48,6 +59,7 @@ public class SysUserController {
 
     @Operation(summary = "新增用户")
     @PostMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> add(@RequestBody SysUser user) {
         sysUserService.addUser(user);
         return Result.ok();
@@ -55,6 +67,7 @@ public class SysUserController {
 
     @Operation(summary = "修改用户")
     @PutMapping
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> update(@RequestBody SysUser user) {
         sysUserService.updateUser(user);
         return Result.ok();
@@ -62,8 +75,23 @@ public class SysUserController {
 
     @Operation(summary = "删除用户")
     @DeleteMapping("/{id}")
+    @PreAuthorize("hasRole('super_admin')")
     public Result<Void> delete(@PathVariable Long id) {
         sysUserService.removeUser(id);
         return Result.ok();
     }
+
+    /**
+     * 绑定/解绑员工企业微信账号
+     * <p>
+     * TODO: 实际企微消息推送由外部对接系统完成,本接口仅保存用户与企微账号的映射关系。
+     * </p>
+     */
+    @Operation(summary = "绑定/解绑企业微信账号")
+    @PutMapping("/{id}/wecom")
+    @PreAuthorize("hasRole('super_admin')")
+    public Result<Void> bindWeCom(@PathVariable Long id, @RequestBody BindWeComDTO dto) {
+        sysUserService.bindWeCom(id, dto.getWecomUserId(), dto.getWecomRemindEnabled());
+        return Result.ok();
+    }
 }

+ 18 - 0
src/main/java/com/qqflow/engine/domain/system/dto/BindWeComDTO.java

@@ -0,0 +1,18 @@
+package com.qqflow.engine.domain.system.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 绑定企业微信账号请求
+ */
+@Data
+@Schema(description = "绑定企业微信账号请求")
+public class BindWeComDTO {
+
+    @Schema(description = "企业微信用户ID(空字符串或 null 表示解绑)")
+    private String wecomUserId;
+
+    @Schema(description = "是否开启企微提醒:0-否 1-是")
+    private Integer wecomRemindEnabled;
+}

+ 20 - 0
src/main/java/com/qqflow/engine/domain/system/dto/ChangePasswordDTO.java

@@ -0,0 +1,20 @@
+package com.qqflow.engine.domain.system.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "修改密码DTO")
+public class ChangePasswordDTO {
+
+    @NotBlank(message = "原密码不能为空")
+    @Schema(description = "原密码")
+    private String oldPassword;
+
+    @NotBlank(message = "新密码不能为空")
+    @Size(min = 6, message = "新密码长度不能少于6位")
+    @Schema(description = "新密码")
+    private String newPassword;
+}

+ 12 - 0
src/main/java/com/qqflow/engine/domain/system/dto/RoleDTO.java

@@ -22,6 +22,9 @@ public class RoleDTO {
     @Schema(description = "登录账号")
     private String username;
 
+    @Schema(description = "手机号")
+    private String phone;
+
     @Schema(description = "角色范围")
     private String roleScope;
 
@@ -37,6 +40,12 @@ public class RoleDTO {
     @Schema(description = "状态:0-禁用 1-正常")
     private Integer status;
 
+    @Schema(description = "企业微信用户ID")
+    private String wecomUserId;
+
+    @Schema(description = "是否开启企微提醒:0-否 1-是")
+    private Integer wecomRemindEnabled;
+
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 
@@ -49,10 +58,13 @@ public class RoleDTO {
         dto.setRoleCode(role.getRoleCode());
         dto.setRoleName(role.getRoleName());
         dto.setUsername(role.getUsername());
+        dto.setPhone(role.getPhone());
         dto.setRoleScope(role.getRoleScope());
         dto.setParentId(role.getParentId());
         dto.setDeptId(role.getDeptId());
         dto.setStatus(role.getStatus());
+        dto.setWecomUserId(role.getWecomUserId());
+        dto.setWecomRemindEnabled(role.getWecomRemindEnabled());
         dto.setCreateTime(role.getCreateTime());
         return dto;
     }

+ 8 - 0
src/main/java/com/qqflow/engine/domain/system/dto/UserDTO.java

@@ -38,6 +38,12 @@ public class UserDTO {
     @Schema(description = "状态:0-正常 1-禁用")
     private Integer status;
 
+    @Schema(description = "企业微信用户ID")
+    private String wecomUserId;
+
+    @Schema(description = "是否开启企微提醒:0-否 1-是")
+    private Integer wecomRemindEnabled;
+
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 
@@ -60,6 +66,8 @@ public class UserDTO {
         dto.setDeptId(user.getDeptId());
         dto.setEmployeeType(user.getEmployeeType());
         dto.setStatus(user.getStatus());
+        dto.setWecomUserId(user.getWecomUserId());
+        dto.setWecomRemindEnabled(user.getWecomRemindEnabled());
         dto.setCreateTime(user.getCreateTime());
         return dto;
     }

+ 28 - 0
src/main/java/com/qqflow/engine/domain/system/dto/WeComConfigDTO.java

@@ -0,0 +1,28 @@
+package com.qqflow.engine.domain.system.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 企业微信配置 DTO
+ * <p>
+ * 仅用于前端配置展示与保存,实际的企业微信消息推送由外部对接系统完成。
+ * 本系统读取该配置后,在触发通知事件时可将配置及接收人信息一并输出(日志/事件)。
+ * </p>
+ */
+@Data
+@Schema(description = "企业微信配置")
+public class WeComConfigDTO {
+
+    @Schema(description = "企业ID")
+    private String corpId;
+
+    @Schema(description = "应用ID")
+    private String agentId;
+
+    @Schema(description = "应用密钥")
+    private String secret;
+
+    @Schema(description = "是否启用:0-否 1-是")
+    private Integer enabled;
+}

+ 47 - 0
src/main/java/com/qqflow/engine/domain/system/entity/SysNotificationConfig.java

@@ -0,0 +1,47 @@
+package com.qqflow.engine.domain.system.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 通知配置表
+ * <p>
+ * 用于存储企业微信、邮件、短信等通知渠道的系统级配置。
+ * 当前阶段仅作配置预留,实际的消息推送由外部对接系统完成。
+ * </p>
+ */
+@Data
+@TableName("sys_notification_config")
+@Schema(description = "通知配置")
+public class SysNotificationConfig {
+
+    @TableId(type = IdType.AUTO)
+    @Schema(description = "配置ID")
+    private Long id;
+
+    @Schema(description = "配置键")
+    private String configKey;
+
+    @Schema(description = "配置值")
+    private String configValue;
+
+    @Schema(description = "配置类型:WECOM/EMAIL/SMS")
+    private String configType;
+
+    @Schema(description = "配置说明")
+    private String description;
+
+    @Schema(description = "状态:0-禁用,1-启用")
+    private Integer status;
+
+    @Schema(description = "创建时间")
+    private LocalDateTime createTime;
+
+    @Schema(description = "更新时间")
+    private LocalDateTime updateTime;
+}

+ 9 - 0
src/main/java/com/qqflow/engine/domain/system/entity/SysRole.java

@@ -36,6 +36,9 @@ public class SysRole {
     @Schema(description = "登录账号")
     private String username;
 
+    @Schema(description = "手机号")
+    private String phone;
+
     @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
     @Schema(description = "密码")
     private String password;
@@ -43,6 +46,12 @@ public class SysRole {
     @Schema(description = "状态:0-禁用 1-正常")
     private Integer status;
 
+    @Schema(description = "企业微信用户ID")
+    private String wecomUserId;
+
+    @Schema(description = "是否开启企微提醒:0-否 1-是")
+    private Integer wecomRemindEnabled;
+
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 }

+ 6 - 0
src/main/java/com/qqflow/engine/domain/system/entity/SysUser.java

@@ -43,6 +43,12 @@ public class SysUser {
     @Schema(description = "状态:0-正常 1-禁用")
     private Integer status;
 
+    @Schema(description = "企业微信用户ID")
+    private String wecomUserId;
+
+    @Schema(description = "是否开启企微提醒:0-否 1-是")
+    private Integer wecomRemindEnabled;
+
     @Schema(description = "创建时间")
     private LocalDateTime createTime;
 

+ 12 - 0
src/main/java/com/qqflow/engine/domain/system/mapper/SysNotificationConfigMapper.java

@@ -0,0 +1,12 @@
+package com.qqflow.engine.domain.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.qqflow.engine.domain.system.entity.SysNotificationConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 通知配置 Mapper
+ */
+@Mapper
+public interface SysNotificationConfigMapper extends BaseMapper<SysNotificationConfig> {
+}

+ 16 - 0
src/main/java/com/qqflow/engine/domain/system/mapper/SysRoleMapper.java

@@ -22,5 +22,21 @@ public interface SysRoleMapper extends BaseMapper<SysRole> {
         );
     }
 
+    default SysRole selectByPhone(String phone) {
+        return this.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysRole>()
+                        .eq(SysRole::getPhone, phone)
+        );
+    }
+
+    default SysRole selectByUsernameOrPhone(String account) {
+        return this.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysRole>()
+                        .eq(SysRole::getUsername, account)
+                        .or()
+                        .eq(SysRole::getPhone, account)
+        );
+    }
+
     List<SysRole> selectRolesByUserId(@Param("userId") Long userId);
 }

+ 2 - 0
src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java

@@ -12,6 +12,8 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
         return this.selectOne(
                 new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
                         .eq(SysUser::getUsername, username)
+                        .or()
+                        .eq(SysUser::getPhone, username)
         );
     }
 

+ 4 - 2
src/main/java/com/qqflow/engine/domain/system/service/RoleAuthService.java

@@ -12,6 +12,8 @@ import org.springframework.stereotype.Service;
 
 import java.util.List;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_ROLE;
+
 @Service
 public class RoleAuthService {
 
@@ -23,7 +25,7 @@ public class RoleAuthService {
     private JwtUtils jwtUtils;
 
     public String login(LoginDTO loginDTO) {
-        SysRole role = sysRoleMapper.selectByUsername(loginDTO.getUsername());
+        SysRole role = sysRoleMapper.selectByUsernameOrPhone(loginDTO.getUsername());
         if (role == null || role.getStatus() == null || role.getStatus() != 1) {
             throw new BusinessException("用户名或密码错误");
         }
@@ -34,7 +36,7 @@ public class RoleAuthService {
         loginUser.setUserId(role.getId());
         loginUser.setUsername(role.getUsername());
         loginUser.setRealName(role.getRoleName());
-        loginUser.setUserType("ROLE");
+        loginUser.setUserType(USER_TYPE_ROLE);
         loginUser.setRoles(List.of(role.getRoleCode()));
         return jwtUtils.generateToken(loginUser);
     }

+ 8 - 0
src/main/java/com/qqflow/engine/domain/system/service/SysDeptService.java

@@ -20,4 +20,12 @@ public interface SysDeptService extends IService<SysDept> {
     Page<SysDept> pageDepts(Page<SysDept> page);
 
     List<DeptDTO> buildDeptTree();
+
+    /**
+     * 收集指定部门及其所有子部门 ID。
+     *
+     * @param rootDeptId 根部门 ID
+     * @return 部门 ID 列表(包含根部门)
+     */
+    List<Long> collectChildDeptIds(Long rootDeptId);
 }

+ 27 - 0
src/main/java/com/qqflow/engine/domain/system/service/SysNotificationConfigService.java

@@ -0,0 +1,27 @@
+package com.qqflow.engine.domain.system.service;
+
+import com.qqflow.engine.domain.system.dto.WeComConfigDTO;
+
+/**
+ * 通知配置服务
+ * <p>
+ * 当前主要用于读取/保存企业微信等渠道的系统级配置。
+ * 实际消息推送能力由外部系统对接,本服务仅提供配置读取入口。
+ * </p>
+ */
+public interface SysNotificationConfigService {
+
+    /**
+     * 获取企业微信配置
+     *
+     * @return 企业微信配置(未配置时返回空对象)
+     */
+    WeComConfigDTO getWeComConfig();
+
+    /**
+     * 保存企业微信配置
+     *
+     * @param dto 企业微信配置
+     */
+    void saveWeComConfig(WeComConfigDTO dto);
+}

+ 2 - 0
src/main/java/com/qqflow/engine/domain/system/service/SysRoleService.java

@@ -19,4 +19,6 @@ public interface SysRoleService extends IService<SysRole> {
     Page<SysRole> pageRoles(Page<SysRole> page, Long deptId, String roleCode, String roleName);
 
     List<SysRole> listRolesByUserId(Long userId);
+
+    void bindWeCom(Long id, String wecomUserId, Integer wecomRemindEnabled);
 }

+ 11 - 0
src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java

@@ -29,4 +29,15 @@ public interface SysUserService extends IService<SysUser> {
     void logout(String token);
 
     String refreshToken(String token);
+
+    void changePassword(Long userId, String oldPassword, String newPassword);
+
+    /**
+     * 绑定/解绑员工企业微信账号
+     *
+     * @param userId            用户ID
+     * @param wecomUserId       企业微信用户ID(空字符串或 null 表示解绑)
+     * @param wecomRemindEnabled 是否开启企微提醒:0-否 1-是
+     */
+    void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled);
 }

+ 41 - 1
src/main/java/com/qqflow/engine/domain/system/service/impl/SysDeptServiceImpl.java

@@ -7,14 +7,20 @@ import com.qqflow.engine.domain.system.dto.DeptDTO;
 import com.qqflow.engine.domain.system.entity.SysDept;
 import com.qqflow.engine.domain.system.mapper.SysDeptMapper;
 import com.qqflow.engine.domain.system.service.SysDeptService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class SysDeptServiceImpl extends ServiceImpl<SysDeptMapper, SysDept>
         implements SysDeptService {
@@ -58,6 +64,35 @@ public class SysDeptServiceImpl extends ServiceImpl<SysDeptMapper, SysDept>
         return this.buildTree(depts);
     }
 
+    @Override
+    public List<Long> collectChildDeptIds(Long rootDeptId) {
+        List<SysDept> allDepts = this.baseMapper.selectDeptList(null);
+        Map<Long, List<SysDept>> parentMap = allDepts.stream()
+                .collect(Collectors.groupingBy(d -> d.getParentId() != null ? d.getParentId() : 0L));
+        List<Long> result = new ArrayList<>();
+        result.add(rootDeptId);
+        Set<Long> visited = new HashSet<>();
+        visited.add(rootDeptId);
+        collectChildren(parentMap, rootDeptId, result, visited);
+        return result;
+    }
+
+    private void collectChildren(Map<Long, List<SysDept>> parentMap, Long parentId, List<Long> result, Set<Long> visited) {
+        List<SysDept> children = parentMap.get(parentId);
+        if (CollectionUtils.isEmpty(children)) {
+            return;
+        }
+        for (SysDept child : children) {
+            Long childId = child.getId();
+            if (childId == null || !visited.add(childId)) {
+                // 已访问过,跳过,防止循环引用导致死递归
+                continue;
+            }
+            result.add(childId);
+            collectChildren(parentMap, childId, result, visited);
+        }
+    }
+
     private List<DeptDTO> buildTree(List<SysDept> depts) {
         List<DeptDTO> dtoList = depts.stream()
                 .map(DeptDTO::of)
@@ -65,12 +100,17 @@ public class SysDeptServiceImpl extends ServiceImpl<SysDeptMapper, SysDept>
                 .collect(Collectors.toList());
         Map<Long, DeptDTO> map = dtoList.stream()
                 .collect(Collectors.toMap(DeptDTO::getId, dto -> dto));
-        List<DeptDTO> roots = new java.util.ArrayList<>();
+        List<DeptDTO> roots = new ArrayList<>();
         for (DeptDTO dto : dtoList) {
             if (dto.getParentId() == null || dto.getParentId() == 0L) {
                 roots.add(dto);
                 continue;
             }
+            if (dto.getParentId().equals(dto.getId())) {
+                log.warn("部门[{}]自引用,已作为根节点处理", dto.getId());
+                roots.add(dto);
+                continue;
+            }
             DeptDTO parent = map.get(dto.getParentId());
             if (parent != null) {
                 parent.getChildren().add(dto);

+ 105 - 0
src/main/java/com/qqflow/engine/domain/system/service/impl/SysNotificationConfigServiceImpl.java

@@ -0,0 +1,105 @@
+package com.qqflow.engine.domain.system.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.qqflow.engine.domain.system.dto.WeComConfigDTO;
+import com.qqflow.engine.domain.system.entity.SysNotificationConfig;
+import com.qqflow.engine.domain.system.mapper.SysNotificationConfigMapper;
+import com.qqflow.engine.domain.system.service.SysNotificationConfigService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 通知配置服务实现
+ * <p>
+ * 配置以 key-value 形式落库,对外提供结构化的企业微信配置读写。
+ * 实际发送逻辑不在这里实现,由外部对接系统消费本系统产生的通知事件。
+ * </p>
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SysNotificationConfigServiceImpl implements SysNotificationConfigService {
+
+    private final SysNotificationConfigMapper sysNotificationConfigMapper;
+
+    private static final String TYPE_WECOM = "WECOM";
+
+    private static final String KEY_CORP_ID = "wecom_corp_id";
+    private static final String KEY_AGENT_ID = "wecom_agent_id";
+    private static final String KEY_SECRET = "wecom_secret";
+    private static final String KEY_ENABLED = "wecom_enabled";
+
+    @Override
+    public WeComConfigDTO getWeComConfig() {
+        List<SysNotificationConfig> list = this.sysNotificationConfigMapper.selectList(
+                Wrappers.<SysNotificationConfig>lambdaQuery()
+                        .eq(SysNotificationConfig::getConfigType, TYPE_WECOM)
+        );
+        Map<String, String> map = new HashMap<>();
+        for (SysNotificationConfig config : list) {
+            if (config.getConfigKey() != null) {
+                map.put(config.getConfigKey(), config.getConfigValue());
+            }
+        }
+        WeComConfigDTO dto = new WeComConfigDTO();
+        dto.setCorpId(map.get(KEY_CORP_ID));
+        dto.setAgentId(map.get(KEY_AGENT_ID));
+        dto.setSecret(map.get(KEY_SECRET));
+        dto.setEnabled(parseEnabled(map.get(KEY_ENABLED)));
+        return dto;
+    }
+
+    @Override
+    @Transactional
+    public void saveWeComConfig(WeComConfigDTO dto) {
+        if (dto == null) {
+            return;
+        }
+        LocalDateTime now = LocalDateTime.now();
+        this.saveOrUpdate(KEY_CORP_ID, dto.getCorpId(), "企业ID", now);
+        this.saveOrUpdate(KEY_AGENT_ID, dto.getAgentId(), "应用ID", now);
+        this.saveOrUpdate(KEY_SECRET, dto.getSecret(), "应用密钥", now);
+        this.saveOrUpdate(KEY_ENABLED, dto.getEnabled() != null ? String.valueOf(dto.getEnabled()) : "1", "是否启用", now);
+    }
+
+    private void saveOrUpdate(String key, String value, String desc, LocalDateTime now) {
+        SysNotificationConfig exist = this.sysNotificationConfigMapper.selectOne(
+                Wrappers.<SysNotificationConfig>lambdaQuery()
+                        .eq(SysNotificationConfig::getConfigKey, key)
+        );
+        if (exist == null) {
+            SysNotificationConfig config = new SysNotificationConfig();
+            config.setConfigKey(key);
+            config.setConfigValue(value);
+            config.setConfigType(TYPE_WECOM);
+            config.setDescription(desc);
+            config.setStatus(1);
+            config.setCreateTime(now);
+            config.setUpdateTime(now);
+            this.sysNotificationConfigMapper.insert(config);
+        } else {
+            exist.setConfigValue(value);
+            exist.setDescription(desc);
+            exist.setUpdateTime(now);
+            this.sysNotificationConfigMapper.updateById(exist);
+        }
+    }
+
+    private Integer parseEnabled(String value) {
+        if (value == null || value.isEmpty()) {
+            return 1;
+        }
+        try {
+            return Integer.valueOf(value);
+        } catch (NumberFormatException e) {
+            return 1;
+        }
+    }
+}

+ 83 - 25
src/main/java/com/qqflow/engine/domain/system/service/impl/SysRoleServiceImpl.java

@@ -3,23 +3,21 @@ package com.qqflow.engine.domain.system.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.qqflow.engine.domain.system.entity.SysDept;
+import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.domain.system.entity.SysRole;
 import com.qqflow.engine.domain.system.entity.SysUserRole;
-import com.qqflow.engine.domain.system.mapper.SysDeptMapper;
 import com.qqflow.engine.domain.system.mapper.SysRoleMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper;
+import com.qqflow.engine.domain.system.service.SysDeptService;
 import com.qqflow.engine.domain.system.service.SysRoleService;
 import jakarta.annotation.Resource;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
 
 @Service
 public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole>
@@ -29,35 +27,84 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole>
     private SysUserRoleMapper sysUserRoleMapper;
 
     @Resource
-    private SysDeptMapper sysDeptMapper;
+    private SysDeptService sysDeptService;
 
     @Resource
     private PasswordEncoder passwordEncoder;
 
     @Override
+    @Transactional
     public boolean addRole(SysRole role) {
-        role.setCreateTime(LocalDateTime.now());
-        if (StringUtils.hasText(role.getPassword())) {
-            role.setPassword(passwordEncoder.encode(role.getPassword()));
+        if (!StringUtils.hasText(role.getRoleCode())) {
+            throw new BusinessException("角色编码不能为空");
+        }
+        if (!StringUtils.hasText(role.getRoleName())) {
+            throw new BusinessException("角色名称不能为空");
+        }
+        if (!StringUtils.hasText(role.getUsername())) {
+            throw new BusinessException("角色登录账号不能为空");
         }
+        if (!StringUtils.hasText(role.getPassword())) {
+            throw new BusinessException("角色登录密码不能为空");
+        }
+        validateRolePassword(role.getPassword());
+        validatePhone(role.getPhone());
+        SysRole exist = this.baseMapper.selectByRoleCode(role.getRoleCode());
+        if (exist != null) {
+            throw new BusinessException("角色编码已存在");
+        }
+        SysRole usernameExist = this.baseMapper.selectByUsername(role.getUsername());
+        if (usernameExist != null) {
+            throw new BusinessException("角色登录账号已存在");
+        }
+        if (StringUtils.hasText(role.getPhone())) {
+            SysRole phoneExist = this.baseMapper.selectByPhone(role.getPhone());
+            if (phoneExist != null) {
+                throw new BusinessException("手机号已存在");
+            }
+        }
+        role.setCreateTime(LocalDateTime.now());
+        role.setPassword(passwordEncoder.encode(role.getPassword()));
         return this.save(role);
     }
 
     @Override
+    @Transactional
     public boolean updateRole(SysRole role) {
+        if (role.getId() == null) {
+            throw new BusinessException("角色ID不能为空");
+        }
+        SysRole existing = this.getById(role.getId());
+        if (existing == null) {
+            throw new BusinessException("角色不存在");
+        }
         if (StringUtils.hasText(role.getPassword())) {
+            validateRolePassword(role.getPassword());
             role.setPassword(passwordEncoder.encode(role.getPassword()));
         } else {
             // 不修改密码时,清空 password 字段避免覆盖
             role.setPassword(null);
         }
+        if (StringUtils.hasText(role.getUsername()) && !role.getUsername().equals(existing.getUsername())) {
+            SysRole usernameExist = this.baseMapper.selectByUsername(role.getUsername());
+            if (usernameExist != null && !usernameExist.getId().equals(role.getId())) {
+                throw new BusinessException("角色登录账号已存在");
+            }
+        }
+        if (StringUtils.hasText(role.getPhone()) && !role.getPhone().equals(existing.getPhone())) {
+            validatePhone(role.getPhone());
+            SysRole phoneExist = this.baseMapper.selectByPhone(role.getPhone());
+            if (phoneExist != null && !phoneExist.getId().equals(role.getId())) {
+                throw new BusinessException("手机号已存在");
+            }
+        }
         return this.updateById(role);
     }
 
     @Override
     public boolean removeRole(Long id) {
         sysUserRoleMapper.delete(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUserRole>()
+                new LambdaQueryWrapper<SysUserRole>()
                         .eq(SysUserRole::getRoleId, id)
         );
         return this.removeById(id);
@@ -72,7 +119,7 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole>
     public Page<SysRole> pageRoles(Page<SysRole> page, Long deptId, String roleCode, String roleName) {
         LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
         if (deptId != null) {
-            List<Long> deptIds = collectChildDeptIds(deptId);
+            List<Long> deptIds = this.sysDeptService.collectChildDeptIds(deptId);
             wrapper.in(SysRole::getDeptId, deptIds);
         }
         if (roleCode != null && !roleCode.isEmpty()) {
@@ -84,24 +131,21 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole>
         return this.page(page, wrapper);
     }
 
-    private List<Long> collectChildDeptIds(Long rootDeptId) {
-        List<SysDept> allDepts = sysDeptMapper.selectDeptList(null);
-        Map<Long, List<SysDept>> parentMap = allDepts.stream()
-                .collect(Collectors.groupingBy(d -> d.getParentId() != null ? d.getParentId() : 0L));
-        List<Long> result = new ArrayList<>();
-        result.add(rootDeptId);
-        collectChildren(parentMap, rootDeptId, result);
-        return result;
+    private void validateRolePassword(String password) {
+        if (password.length() < 6) {
+            throw new BusinessException("密码长度不能少于6位");
+        }
+        if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) {
+            throw new BusinessException("密码必须同时包含字母和数字");
+        }
     }
 
-    private void collectChildren(Map<Long, List<SysDept>> parentMap, Long parentId, List<Long> result) {
-        List<SysDept> children = parentMap.get(parentId);
-        if (children == null || children.isEmpty()) {
+    private void validatePhone(String phone) {
+        if (!StringUtils.hasText(phone)) {
             return;
         }
-        for (SysDept child : children) {
-            result.add(child.getId());
-            collectChildren(parentMap, child.getId(), result);
+        if (!phone.matches("^1[3-9]\\d{9}$")) {
+            throw new BusinessException("手机号格式不正确");
         }
     }
 
@@ -109,4 +153,18 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole>
     public List<SysRole> listRolesByUserId(Long userId) {
         return this.baseMapper.selectRolesByUserId(userId);
     }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void bindWeCom(Long id, String wecomUserId, Integer wecomRemindEnabled) {
+        SysRole role = this.getById(id);
+        if (role == null) {
+            throw new BusinessException("角色不存在");
+        }
+        SysRole update = new SysRole();
+        update.setId(id);
+        update.setWecomUserId(wecomUserId);
+        update.setWecomRemindEnabled(wecomRemindEnabled);
+        this.updateById(update);
+    }
 }

+ 118 - 23
src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java

@@ -15,8 +15,10 @@ import com.qqflow.engine.domain.system.entity.SysUserRole;
 import com.qqflow.engine.domain.system.mapper.SysDeptMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserMapper;
 import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper;
+import com.qqflow.engine.domain.system.service.SysDeptService;
 import com.qqflow.engine.domain.system.service.SysUserService;
 import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -26,13 +28,20 @@ import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
+import static com.qqflow.engine.common.constant.SecurityConstants.EMPLOYEE_TYPE_COMMON_USER;
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
+@Slf4j
 @Service
 public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
         implements SysUserService, UserDetailsService {
@@ -44,6 +53,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     private SysDeptMapper sysDeptMapper;
 
     @Resource
+    private SysDeptService sysDeptService;
+
+    @Resource
     private PasswordEncoder passwordEncoder;
 
     @Lazy
@@ -57,17 +69,24 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     private RedisCache redisCache;
 
     @Override
+    @Transactional
     public boolean addUser(SysUser user) {
-        if (user.getUsername() == null || user.getPassword() == null) {
-            throw new BusinessException("用户名或密码不能为空");
+        if (user.getUsername() == null || user.getUsername().isBlank()) {
+            throw new BusinessException("用户名不能为空");
         }
+        if (user.getPassword() == null || user.getPassword().isEmpty()) {
+            throw new BusinessException("密码不能为空");
+        }
+        validatePassword(user.getPassword());
+        validatePhone(user.getPhone());
+        validateEmail(user.getEmail());
         SysUser exist = this.baseMapper.selectByUsername(user.getUsername());
         if (exist != null) {
             throw new BusinessException("用户名已存在");
         }
         user.setPassword(passwordEncoder.encode(user.getPassword()));
         if (user.getEmployeeType() == null || user.getEmployeeType().isEmpty()) {
-            user.setEmployeeType("common_user");
+            user.setEmployeeType(EMPLOYEE_TYPE_COMMON_USER);
         }
         user.setCreateTime(LocalDateTime.now());
         user.setUpdateTime(LocalDateTime.now());
@@ -84,11 +103,15 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     }
 
     @Override
+    @Transactional
     public boolean updateUser(SysUser user) {
         if (user.getId() == null) {
             throw new BusinessException("用户ID不能为空");
         }
+        validatePhone(user.getPhone());
+        validateEmail(user.getEmail());
         if (user.getPassword() != null && !user.getPassword().isEmpty()) {
+            validatePassword(user.getPassword());
             user.setPassword(passwordEncoder.encode(user.getPassword()));
         } else {
             user.setPassword(null);
@@ -134,7 +157,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
             wrapper.eq(SysUser::getStatus, status);
         }
         if (deptId != null) {
-            List<Long> deptIds = collectChildDeptIds(deptId);
+            List<Long> deptIds = this.sysDeptService.collectChildDeptIds(deptId);
             wrapper.in(SysUser::getDeptId, deptIds);
         }
         Page<SysUser> userPage = this.page(new Page<>(page.getCurrent(), page.getSize()), wrapper);
@@ -158,6 +181,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
             dto.setDeptName(deptNameMap.getOrDefault(user.getDeptId(), ""));
             dto.setEmployeeType(user.getEmployeeType());
             dto.setStatus(user.getStatus());
+            dto.setWecomUserId(user.getWecomUserId());
+            dto.setWecomRemindEnabled(user.getWecomRemindEnabled());
             dto.setCreateTime(user.getCreateTime());
             return dto;
         }).collect(Collectors.toList());
@@ -166,24 +191,30 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
         return resultPage;
     }
 
-    private List<Long> collectChildDeptIds(Long rootDeptId) {
-        List<SysDept> allDepts = sysDeptMapper.selectDeptList(null);
-        Map<Long, List<SysDept>> parentMap = allDepts.stream()
-                .collect(Collectors.groupingBy(d -> d.getParentId() != null ? d.getParentId() : 0L));
-        List<Long> result = new ArrayList<>();
-        result.add(rootDeptId);
-        collectChildren(parentMap, rootDeptId, result);
-        return result;
+    private void validatePassword(String password) {
+        if (password == null || password.length() < 6) {
+            throw new BusinessException("密码长度不能少于6位");
+        }
+        if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) {
+            throw new BusinessException("密码必须同时包含字母和数字");
+        }
     }
 
-    private void collectChildren(Map<Long, List<SysDept>> parentMap, Long parentId, List<Long> result) {
-        List<SysDept> children = parentMap.get(parentId);
-        if (children == null || children.isEmpty()) {
+    private void validatePhone(String phone) {
+        if (phone == null || phone.isEmpty()) {
             return;
         }
-        for (SysDept child : children) {
-            result.add(child.getId());
-            collectChildren(parentMap, child.getId(), result);
+        if (!phone.matches("^1[3-9]\\d{9}$")) {
+            throw new BusinessException("手机号格式不正确");
+        }
+    }
+
+    private void validateEmail(String email) {
+        if (email == null || email.isEmpty()) {
+            return;
+        }
+        if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
+            throw new BusinessException("邮箱格式不正确");
         }
     }
 
@@ -203,21 +234,45 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
                 new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword())
         );
         LoginUser loginUser = (LoginUser) authentication.getPrincipal();
-        String token = jwtUtils.generateToken(loginUser);
-        return token;
+        return jwtUtils.generateToken(loginUser);
     }
 
     @Override
     public void logout(String token) {
-        // JWT 无状态登出,客户端清除 token 即可
+        if (!StringUtils.hasText(token)) {
+            return;
+        }
+        String realToken = token.startsWith("Bearer ") ? token.substring(7) : token;
+        try {
+            String uuid = jwtUtils.getUuidFromToken(realToken);
+            long remaining = jwtUtils.getRemainingTime(realToken);
+            if (remaining > 0) {
+                redisCache.setCacheObject("token:blacklist:" + uuid, "1", remaining, TimeUnit.MILLISECONDS);
+            }
+        } catch (Exception e) {
+            log.warn("登出时解析 token 失败: {}", e.getMessage());
+        }
     }
 
     @Override
     public String refreshToken(String token) {
-        if (!token.startsWith("Bearer ")) {
+        if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
             throw new BusinessException("token格式错误");
         }
         String realToken = token.substring(7);
+        // 校验 token 是否已被拉黑
+        try {
+            String uuid = jwtUtils.getUuidFromToken(realToken);
+            Object blacklisted = redisCache.getCacheObject("token:blacklist:" + uuid);
+            if (blacklisted != null) {
+                throw new BusinessException("登录已过期,请重新登录");
+            }
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.warn("刷新 token 时解析失败: {}", e.getMessage());
+            throw new BusinessException("登录已过期");
+        }
         LoginUser loginUser = jwtUtils.parseLoginUser(realToken);
         if (loginUser == null) {
             throw new BusinessException("登录已过期");
@@ -226,6 +281,46 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     }
 
     @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled) {
+        if (userId == null) {
+            throw new BusinessException("用户ID不能为空");
+        }
+        SysUser user = this.getById(userId);
+        if (user == null) {
+            throw new BusinessException("用户不存在");
+        }
+        SysUser update = new SysUser();
+        update.setId(userId);
+        update.setWecomUserId(wecomUserId);
+        update.setWecomRemindEnabled(wecomRemindEnabled);
+        update.setUpdateTime(LocalDateTime.now());
+        this.updateById(update);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void changePassword(Long userId, String oldPassword, String newPassword) {
+        if (userId == null) {
+            throw new BusinessException("用户ID不能为空");
+        }
+        if (!StringUtils.hasText(oldPassword) || !StringUtils.hasText(newPassword)) {
+            throw new BusinessException("密码不能为空");
+        }
+        SysUser user = this.getById(userId);
+        if (user == null) {
+            throw new BusinessException("用户不存在");
+        }
+        if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
+            throw new BusinessException("原密码错误");
+        }
+        validatePassword(newPassword);
+        user.setPassword(passwordEncoder.encode(newPassword));
+        user.setUpdateTime(LocalDateTime.now());
+        this.updateById(user);
+    }
+
+    @Override
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
         SysUser user = this.baseMapper.selectByUsername(username);
         if (user == null) {
@@ -240,7 +335,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
         loginUser.setRealName(user.getRealName());
         loginUser.setDeptId(user.getDeptId());
         loginUser.setEmployeeType(user.getEmployeeType());
-        loginUser.setUserType("SYSTEM");
+        loginUser.setUserType(USER_TYPE_SYSTEM);
         loginUser.setPassword(user.getPassword());
         loginUser.setRoles(this.loadUserRoles(user.getId()));
         return loginUser;

+ 5 - 1
src/main/resources/application-dev.yml

@@ -18,6 +18,10 @@ spring:
       path: /h2-console
       settings:
         web-allow-others: true
+  servlet:
+    multipart:
+      max-file-size: 10MB
+      max-request-size: 50MB
   data:
     redis:
       host: localhost
@@ -36,7 +40,7 @@ mybatis-plus:
       logic-not-delete-value: 0
 
 jwt:
-  secret: qqflow-dev-secret-key-2024-very-long-and-secure
+  secret: ${JWT_SECRET:qqflow-dev-secret-key-2024-very-long-and-secure}
   expiration: 86400000
 
 logging:

+ 8 - 4
src/main/resources/application.yml

@@ -11,9 +11,9 @@ spring:
     password: root
   sql:
     init:
-      mode: always
-      schema-locations: classpath:schema-dev.sql
-      data-locations: classpath:data-dev.sql
+      mode: never
+#      schema-locations: classpath:schema-mysql.sql
+#      data-locations: classpath:data-mysql.sql
   redis:
     host: localhost
     port: 6379
@@ -25,6 +25,10 @@ spring:
         max-active: 8
         max-idle: 8
         min-idle: 0
+  servlet:
+    multipart:
+      max-file-size: 10MB
+      max-request-size: 50MB
   jackson:
     date-format: yyyy-MM-dd HH:mm:ss
     time-zone: GMT+8
@@ -44,7 +48,7 @@ mybatis-plus:
       logic-not-delete-value: 0
 
 jwt:
-  secret: qqflow-engine-secret-key-2024-very-long-and-secure
+  secret: ${JWT_SECRET:qqflow-engine-secret-key-2024-very-long-and-secure}
   expiration: 86400000
   header: Authorization
   prefix: Bearer

+ 9 - 9
src/main/resources/data-dev.sql

@@ -7,16 +7,16 @@ MERGE INTO sys_dept (id, dept_name, dept_code, parent_id, sort_order, status) KE
 (6, '后端组', 'BE', 2, 2, 1);
 
 MERGE INTO sys_user (id, username, password, real_name, phone, email, dept_id, employee_type, status) KEY(id) VALUES
-(1, 'admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '超级管理员', '13800138000', 'admin@qqflow.com', 1, 'super_admin', 0),
-(2, 'zhangsan', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '张三', '13800138001', 'zhangsan@qqflow.com', 2, 'common_user', 0),
-(3, 'lisi', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '李四', '13800138002', 'lisi@qqflow.com', 3, 'dept_manager', 0),
-(4, 'wangwu', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '王五', '13800138003', 'wangwu@qqflow.com', 4, 'flow_manager', 0);
+(1, 'admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '超级管理员', '13800138000', 'admin@qqflow.com', 1, 'super_admin', 0),
+(2, 'zhangsan', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '张三', '13800138001', 'zhangsan@qqflow.com', 2, 'common_user', 0),
+(3, 'lisi', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '李四', '13800138002', 'lisi@qqflow.com', 3, 'dept_manager', 0),
+(4, 'wangwu', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '王五', '13800138003', 'wangwu@qqflow.com', 4, 'flow_manager', 0);
 
-MERGE INTO sys_role (id, role_code, role_name, role_scope, parent_id, dept_id, username, password, status) KEY(id) VALUES
-(1, 'super_admin', '超级管理员', 'platform', 0, 1, 'role_super_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(2, 'flow_admin', '流程管理员', 'tenant', 0, 1, 'role_flow_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(3, 'normal_user', '普通用户', 'tenant', 0, 2, 'role_normal_user', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(4, 'dept_manager', '部经理', 'tenant', 3, 3, 'role_dept_manager', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1);
+MERGE INTO sys_role (id, role_code, role_name, role_scope, parent_id, dept_id, username, password, status, phone) KEY(id) VALUES
+(1, 'super_admin', '总裁办专员', 'platform', 0, 1, 'role_super_admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000001'),
+(2, 'flow_admin', '流程运维专员', 'tenant', 0, 1, 'role_flow_admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000002'),
+(3, 'normal_user', '技术部员工', 'tenant', 0, 2, 'role_normal_user', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000003'),
+(4, 'dept_manager', '财务部经理', 'tenant', 3, 3, 'role_dept_manager', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000004');
 
 MERGE INTO sys_user_role (id, user_id, role_id) KEY(id) VALUES
 (1, 1, 1),

+ 30 - 0
src/main/resources/data-mysql.sql

@@ -0,0 +1,30 @@
+-- ========================================================
+-- MySQL 初始化数据脚本
+-- 与 data-dev.sql 语义一致,使用标准 MySQL 语法
+-- ========================================================
+
+INSERT IGNORE INTO sys_dept (id, dept_name, dept_code, parent_id, sort_order, status) VALUES
+(1, '总裁办', 'CEO', 0, 1, 1),
+(2, '技术部', 'TECH', 0, 2, 1),
+(3, '财务部', 'FIN', 0, 3, 1),
+(4, '人事部', 'HR', 0, 4, 1),
+(5, '前端组', 'FE', 2, 1, 1),
+(6, '后端组', 'BE', 2, 2, 1);
+
+INSERT IGNORE INTO sys_user (id, username, password, real_name, phone, email, dept_id, employee_type, status) VALUES
+(1, 'admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '超级管理员', '13800138000', 'admin@qqflow.com', 1, 'super_admin', 0),
+(2, 'zhangsan', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '张三', '13800138001', 'zhangsan@qqflow.com', 2, 'common_user', 0),
+(3, 'lisi', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '李四', '13800138002', 'lisi@qqflow.com', 3, 'dept_manager', 0),
+(4, 'wangwu', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', '王五', '13800138003', 'wangwu@qqflow.com', 4, 'flow_manager', 0);
+
+INSERT IGNORE INTO sys_role (id, role_code, role_name, role_scope, parent_id, dept_id, username, password, status, phone) VALUES
+(1, 'super_admin', '总裁办专员', 'platform', 0, 1, 'role_super_admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000001'),
+(2, 'flow_admin', '流程运维专员', 'tenant', 0, 1, 'role_flow_admin', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000002'),
+(3, 'normal_user', '技术部员工', 'tenant', 0, 2, 'role_normal_user', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000003'),
+(4, 'dept_manager', '财务部经理', 'tenant', 3, 3, 'role_dept_manager', '$2a$10$TGAG9dOoBWlslF3EjezT4eyIUgdW1X.YF4lLpNq4E8U9IaXRgZ8K2', 1, '13900000004');
+
+INSERT IGNORE INTO sys_user_role (id, user_id, role_id) VALUES
+(1, 1, 1),
+(2, 2, 4),
+(3, 3, 3),
+(4, 4, 3);

+ 5 - 5
src/main/resources/data-test.sql

@@ -12,11 +12,11 @@ MERGE INTO sys_user (id, username, password, real_name, phone, email, dept_id, e
 (3, 'lisi', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '李四', '13800138002', 'lisi@qqflow.com', 3, 'dept_manager', 0),
 (4, 'wangwu', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', '王五', '13800138003', 'wangwu@qqflow.com', 4, 'flow_manager', 0);
 
-MERGE INTO sys_role (id, role_code, role_name, role_scope, parent_id, dept_id, username, password, status) KEY(id) VALUES
-(1, 'super_admin', '超级管理员', 'platform', 0, 1, 'role_super_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(2, 'flow_admin', '流程管理员', 'tenant', 0, 1, 'role_flow_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(3, 'normal_user', '普通用户', 'tenant', 0, 2, 'role_normal_user', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1),
-(4, 'dept_manager', '部经理', 'tenant', 3, 3, 'role_dept_manager', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1);
+MERGE INTO sys_role (id, role_code, role_name, role_scope, parent_id, dept_id, username, password, status, phone) KEY(id) VALUES
+(1, 'super_admin', '总裁办专员', 'platform', 0, 1, 'role_super_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1, '13900000001'),
+(2, 'flow_admin', '流程运维专员', 'tenant', 0, 1, 'role_flow_admin', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1, '13900000002'),
+(3, 'normal_user', '技术部员工', 'tenant', 0, 2, 'role_normal_user', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1, '13900000003'),
+(4, 'dept_manager', '财务部经理', 'tenant', 3, 3, 'role_dept_manager', '$2a$10$je3eVNDSkYWM929JHtmsEOL0eS4GB6s7UObxx91PmmppoFwnfmTty', 1, '13900000004');
 
 MERGE INTO sys_user_role (id, user_id, role_id) KEY(id) VALUES
 (1, 1, 1),

+ 225 - 0
src/main/resources/mapper/flow/AnalysisMapper.xml

@@ -0,0 +1,225 @@
+<?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.flow.mapper.AnalysisMapper">
+
+    <resultMap id="ProcessEfficiencyResultMap" type="com.qqflow.engine.domain.flow.dto.analysis.ProcessEfficiencyDTO">
+        <id column="process_definition_id" property="processDefinitionId"/>
+        <result column="process_name" property="processName"/>
+        <result column="instance_count" property="instanceCount"/>
+        <result column="avg_duration_minutes" property="avgDurationMinutes"/>
+        <result column="max_duration_minutes" property="maxDurationMinutes"/>
+        <result column="min_duration_minutes" property="minDurationMinutes"/>
+    </resultMap>
+
+    <resultMap id="NodeStayStatResultMap" type="com.qqflow.engine.domain.flow.dto.analysis.NodeStayStatDTO">
+        <id column="node_id" property="nodeId"/>
+        <result column="node_name" property="nodeName"/>
+        <result column="process_definition_id" property="processDefinitionId"/>
+        <result column="process_name" property="processName"/>
+        <result column="task_count" property="taskCount"/>
+        <result column="avg_stay_minutes" property="avgStayMinutes"/>
+        <result column="max_stay_minutes" property="maxStayMinutes"/>
+    </resultMap>
+
+    <resultMap id="StuckInstanceResultMap" type="com.qqflow.engine.domain.flow.dto.analysis.StuckInstanceDTO">
+        <id column="instance_id" property="instanceId"/>
+        <result column="instance_no" property="instanceNo"/>
+        <result column="title" property="title"/>
+        <result column="process_definition_id" property="processDefinitionId"/>
+        <result column="process_name" property="processName"/>
+        <result column="applicant_id" property="applicantId"/>
+        <result column="applicant_name" property="applicantName"/>
+        <result column="node_id" property="nodeId"/>
+        <result column="node_name" property="nodeName"/>
+        <result column="task_create_time" property="taskCreateTime"/>
+        <result column="stay_minutes" property="stayMinutes"/>
+    </resultMap>
+
+    <!-- 已完成流程效率统计(H2 / MySQL 通用) -->
+    <select id="selectCompletedEfficiency" resultMap="ProcessEfficiencyResultMap">
+        SELECT
+            pd.id AS process_definition_id,
+            pd.process_name AS process_name,
+            COUNT(pi.id) AS instance_count,
+            ROUND(AVG(TIMESTAMPDIFF(MINUTE, pi.start_time, pi.end_time))) AS avg_duration_minutes,
+            MAX(TIMESTAMPDIFF(MINUTE, pi.start_time, pi.end_time)) AS max_duration_minutes,
+            MIN(TIMESTAMPDIFF(MINUTE, pi.start_time, pi.end_time)) AS min_duration_minutes
+        FROM bpm_process_instance pi
+        INNER JOIN bpm_process_definition pd ON pi.process_definition_id = pd.id
+        WHERE pi.deleted = 0
+          AND pi.status = 5
+          AND pi.end_time IS NOT NULL
+        <if test="processDefinitionId != null">
+            AND pi.process_definition_id = #{processDefinitionId}
+        </if>
+        <if test="startTime != null">
+            AND pi.end_time &gt;= #{startTime}
+        </if>
+        <if test="endTime != null">
+            AND pi.end_time &lt;= #{endTime}
+        </if>
+        GROUP BY pd.id, pd.process_name
+        ORDER BY avg_duration_minutes DESC
+        LIMIT 20
+    </select>
+
+    <!-- 进行中流程节点停留统计 -->
+    <select id="selectInProgressByNode" resultMap="NodeStayStatResultMap">
+        SELECT
+            t.node_id AS node_id,
+            t.node_name AS node_name,
+            pi.process_definition_id AS process_definition_id,
+            pd.process_name AS process_name,
+            COUNT(t.id) AS task_count,
+            ROUND(AVG(TIMESTAMPDIFF(MINUTE, t.create_time, NOW()))) AS avg_stay_minutes,
+            MAX(TIMESTAMPDIFF(MINUTE, t.create_time, NOW())) AS max_stay_minutes
+        FROM bpm_approval_task t
+        INNER JOIN bpm_process_instance pi ON t.instance_id = pi.id
+        LEFT JOIN bpm_process_definition pd ON pi.process_definition_id = pd.id
+        WHERE t.deleted = 0
+          AND t.task_status = 0
+          AND t.node_type != 'cc'
+          AND pi.deleted = 0
+          AND pi.status IN (0, 1, 4)
+        <if test="processDefinitionId != null">
+            AND pi.process_definition_id = #{processDefinitionId}
+        </if>
+        GROUP BY t.node_id, t.node_name, pi.process_definition_id, pd.process_name
+        ORDER BY avg_stay_minutes DESC
+        LIMIT 20
+    </select>
+
+    <!-- 卡住流程实例数量 -->
+    <select id="countStuckInstances" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM bpm_approval_task t
+        INNER JOIN bpm_process_instance pi ON t.instance_id = pi.id
+        LEFT JOIN bpm_process_definition pd ON pi.process_definition_id = pd.id
+        WHERE t.deleted = 0
+          AND t.task_status = 0
+          AND t.node_type != 'cc'
+          AND pi.deleted = 0
+          AND pi.status IN (0, 1, 4)
+        <if test="nodeId != null and nodeId != ''">
+            AND t.node_id = #{nodeId}
+        </if>
+        <if test="processDefinitionId != null">
+            AND pi.process_definition_id = #{processDefinitionId}
+        </if>
+        <if test="minStayMinutes != null">
+            AND TIMESTAMPDIFF(MINUTE, t.create_time, NOW()) &gt;= #{minStayMinutes}
+        </if>
+    </select>
+
+    <!-- 卡住流程实例明细 -->
+    <select id="selectStuckInstances" resultMap="StuckInstanceResultMap">
+        SELECT
+            pi.id AS instance_id,
+            pi.instance_no AS instance_no,
+            pi.title AS title,
+            pi.process_definition_id AS process_definition_id,
+            pd.process_name AS process_name,
+            pi.applicant_id AS applicant_id,
+            su.real_name AS applicant_name,
+            t.node_id AS node_id,
+            t.node_name AS node_name,
+            t.create_time AS task_create_time,
+            TIMESTAMPDIFF(MINUTE, t.create_time, NOW()) AS stay_minutes
+        FROM bpm_approval_task t
+        INNER JOIN bpm_process_instance pi ON t.instance_id = pi.id
+        LEFT JOIN bpm_process_definition pd ON pi.process_definition_id = pd.id
+        LEFT JOIN sys_user su ON pi.applicant_id = su.id
+        WHERE t.deleted = 0
+          AND t.task_status = 0
+          AND t.node_type != 'cc'
+          AND pi.deleted = 0
+          AND pi.status IN (0, 1, 4)
+        <if test="nodeId != null and nodeId != ''">
+            AND t.node_id = #{nodeId}
+        </if>
+        <if test="processDefinitionId != null">
+            AND pi.process_definition_id = #{processDefinitionId}
+        </if>
+        <if test="minStayMinutes != null">
+            AND TIMESTAMPDIFF(MINUTE, t.create_time, NOW()) &gt;= #{minStayMinutes}
+        </if>
+        ORDER BY stay_minutes DESC
+        LIMIT #{pageSize} OFFSET #{offset}
+    </select>
+
+    <!-- 流程实例概览统计 -->
+    <select id="selectInstanceOverview" resultType="com.qqflow.engine.domain.flow.dto.analysis.AnalysisOverviewDTO">
+        SELECT
+            COUNT(id) AS total_instances,
+            SUM(CASE WHEN status = 5 THEN 1 ELSE 0 END) AS completed_count,
+            SUM(CASE WHEN status IN (0, 1, 4) THEN 1 ELSE 0 END) AS running_count,
+            SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS rejected_count,
+            SUM(CASE WHEN status = 6 THEN 1 ELSE 0 END) AS revoked_count,
+            IFNULL(ROUND(AVG(CASE WHEN status = 5 THEN TIMESTAMPDIFF(MINUTE, start_time, end_time) END)), 0) AS avg_duration_minutes
+        FROM bpm_process_instance
+        WHERE deleted = 0
+        <if test="startTime != null">
+            AND start_time &gt;= #{startTime}
+        </if>
+        <if test="endTime != null">
+            AND start_time &lt;= #{endTime}
+        </if>
+        <if test="processDefinitionId != null">
+            AND process_definition_id = #{processDefinitionId}
+        </if>
+    </select>
+
+    <!-- 超时任务数 -->
+    <select id="countTimeoutTasks" resultType="java.lang.Long">
+        SELECT COUNT(t.id)
+        FROM bpm_approval_task t
+        INNER JOIN bpm_process_instance pi ON t.instance_id = pi.id
+        WHERE t.deleted = 0
+          AND t.task_status = 0
+          AND t.timeout_time IS NOT NULL
+          AND t.timeout_time &lt; NOW()
+          AND pi.deleted = 0
+          AND pi.status IN (0, 1, 4)
+        <if test="processDefinitionId != null">
+            AND pi.process_definition_id = #{processDefinitionId}
+        </if>
+    </select>
+
+    <!-- 流程状态分布 -->
+    <select id="selectStatusDistribution" resultType="com.qqflow.engine.domain.flow.dto.analysis.StatusDistributionDTO">
+        SELECT
+            status,
+            COUNT(*) AS count
+        FROM bpm_process_instance
+        WHERE deleted = 0
+        <if test="startTime != null">
+            AND start_time &gt;= #{startTime}
+        </if>
+        <if test="endTime != null">
+            AND start_time &lt;= #{endTime}
+        </if>
+        <if test="processDefinitionId != null">
+            AND process_definition_id = #{processDefinitionId}
+        </if>
+        GROUP BY status
+        ORDER BY status
+    </select>
+
+    <!-- 近30天流程趋势 -->
+    <select id="selectTrend" resultType="com.qqflow.engine.domain.flow.dto.analysis.TrendDTO">
+        SELECT
+            CAST(DATE(start_time) AS CHAR) AS date,
+            COUNT(*) AS started_count,
+            SUM(CASE WHEN status = 5 THEN 1 ELSE 0 END) AS completed_count,
+            SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS rejected_count
+        FROM bpm_process_instance
+        WHERE deleted = 0
+          AND DATE(start_time) BETWEEN #{startDate} AND #{endDate}
+        <if test="processDefinitionId != null">
+            AND process_definition_id = #{processDefinitionId}
+        </if>
+        GROUP BY CAST(DATE(start_time) AS CHAR)
+        ORDER BY date
+    </select>
+
+</mapper>

+ 3 - 0
src/main/resources/mapper/flow/ApprovalRecordMapper.xml

@@ -15,6 +15,9 @@
         <result column="comment" property="comment"/>
         <result column="attachment_urls" property="attachmentUrls"/>
         <result column="create_time" property="createTime"/>
+        <result column="deleted" property="deleted"/>
+        <result column="instanceTitle" property="instanceTitle"/>
+        <result column="instanceNo" property="instanceNo"/>
     </resultMap>
 
     <select id="selectRecordListByInstanceId" resultMap="BaseResultMap">

+ 12 - 0
src/main/resources/mapper/flow/ApprovalTaskMapper.xml

@@ -18,6 +18,8 @@
         <result column="timeout_action" property="timeoutAction"/>
         <result column="create_time" property="createTime"/>
         <result column="handle_time" property="handleTime"/>
+        <result column="version" property="version"/>
+        <result column="deleted" property="deleted"/>
         <result column="process_name" property="processName"/>
         <result column="instance_title" property="instanceTitle"/>
         <result column="instance_no" property="instanceNo"/>
@@ -31,6 +33,7 @@
         WHERE t.deleted = 0
           AND t.assignee_id = #{assigneeId}
           AND t.task_status = 0
+          AND t.node_type != 'cc'
           <choose>
               <when test="assigneeType == 'ROLE'">
                   AND t.assignee_type = 'ROLE'
@@ -53,6 +56,7 @@
         WHERE t.deleted = 0
           AND t.assignee_id = #{assigneeId}
           AND t.task_status IN (1, 2, 4)
+          AND t.node_type != 'cc'
           <choose>
               <when test="assigneeType == 'ROLE'">
                   AND t.assignee_type = 'ROLE'
@@ -75,6 +79,14 @@
         WHERE t.deleted = 0
           AND t.assignee_id = #{assigneeId}
           AND t.node_type = 'cc'
+          <choose>
+              <when test="assigneeType == 'ROLE'">
+                  AND t.assignee_type = 'ROLE'
+              </when>
+              <otherwise>
+                  AND (t.assignee_type IS NULL OR t.assignee_type = '' OR t.assignee_type = 'USER')
+              </otherwise>
+          </choose>
         <if test="processName != null and processName != ''">
             AND pd.process_name LIKE CONCAT('%', #{processName}, '%')
         </if>

+ 3 - 1
src/main/resources/mapper/flow/ProcessDefinitionMapper.xml

@@ -6,8 +6,9 @@
         <id column="id" property="id"/>
         <result column="process_code" property="processCode"/>
         <result column="process_name" property="processName"/>
-        <result column="category_id" property="categoryId"/>
+        <result column="category" property="category"/>
         <result column="form_id" property="formId"/>
+        <result column="form_schema" property="formSchema"/>
         <result column="model_json" property="modelJson"/>
         <result column="version" property="version"/>
         <result column="status" property="status"/>
@@ -16,6 +17,7 @@
         <result column="create_time" property="createTime"/>
         <result column="update_by" property="updateBy"/>
         <result column="update_time" property="updateTime"/>
+        <result column="deleted" property="deleted"/>
     </resultMap>
 
 </mapper>

+ 12 - 1
src/main/resources/mapper/flow/ProcessInstanceMapper.xml

@@ -18,6 +18,7 @@
         <result column="end_time" property="endTime"/>
         <result column="create_time" property="createTime"/>
         <result column="update_time" property="updateTime"/>
+        <result column="deleted" property="deleted"/>
         <result column="process_name" property="processName"/>
     </resultMap>
 
@@ -41,7 +42,17 @@
         LEFT JOIN bpm_process_definition pd ON pi.process_definition_id = pd.id
         LEFT JOIN bpm_approval_task t ON pi.id = t.instance_id AND t.deleted = 0
         WHERE pi.deleted = 0
-          AND (pi.applicant_id = #{userId} OR t.assignee_id = #{userId})
+          AND (
+              pi.applicant_id = #{userId}
+              <choose>
+                  <when test="userType == 'ROLE'">
+                      OR (t.assignee_id = #{userId} AND t.assignee_type = 'ROLE')
+                  </when>
+                  <otherwise>
+                      OR (t.assignee_id = #{userId} AND (t.assignee_type IS NULL OR t.assignee_type = '' OR t.assignee_type = 'USER'))
+                  </otherwise>
+              </choose>
+          )
         ORDER BY pi.create_time DESC
     </select>
 

+ 74 - 9
src/main/resources/schema-dev.sql

@@ -5,9 +5,11 @@ CREATE TABLE IF NOT EXISTS sys_user (
     real_name VARCHAR(50),
     phone VARCHAR(20),
     email VARCHAR(100),
-    dept_id BIGINT DEFAULT 0,
+    dept_id BIGINT DEFAULT NULL,
     employee_type VARCHAR(20) DEFAULT 'common_user',
     status TINYINT DEFAULT 0,
+    wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -19,9 +21,11 @@ CREATE TABLE IF NOT EXISTS sys_role (
     role_scope VARCHAR(20) DEFAULT 'tenant',
     parent_id BIGINT DEFAULT 0,
     dept_id BIGINT DEFAULT 0,
-    username VARCHAR(50),
+    username VARCHAR(50) UNIQUE,
     password VARCHAR(100),
     status TINYINT DEFAULT 1,
+    wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
@@ -49,16 +53,17 @@ CREATE TABLE IF NOT EXISTS bpm_process_definition (
     process_name VARCHAR(100) NOT NULL,
     category VARCHAR(50),
     form_id BIGINT,
+    form_schema TEXT COMMENT '流程表单字段定义 JSON',
     model_json TEXT NOT NULL,
     version INT DEFAULT 1,
-    status TINYINT DEFAULT 0,
+    status TINYINT DEFAULT 0 COMMENT '0-设计中 1-启用中 2-历史',
     description VARCHAR(500),
     create_by BIGINT,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_by BIGINT,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     deleted TINYINT DEFAULT 0,
-    UNIQUE KEY uk_process_code_version (process_code, version)
+    UNIQUE KEY uk_process_code_version (process_code, version, deleted)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS bpm_process_instance (
@@ -72,13 +77,17 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     form_data TEXT,
     attachment_urls TEXT,
     current_node_id VARCHAR(50),
-    status TINYINT DEFAULT 0,
+    status TINYINT DEFAULT 0 COMMENT '0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
     result VARCHAR(20),
     start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     end_time TIMESTAMP NULL,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_instance_definition_id (process_definition_id),
+    KEY idx_instance_applicant (applicant_id),
+    KEY idx_instance_status (status),
+    KEY idx_instance_current_node (current_node_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS bpm_approval_task (
@@ -89,17 +98,45 @@ CREATE TABLE IF NOT EXISTS bpm_approval_task (
     node_type VARCHAR(20),
     assignee_id BIGINT,
     assignee_type VARCHAR(20),
-    task_status TINYINT DEFAULT 0,
+    task_status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已转办 3已跳过 4已回退',
     approval_result VARCHAR(20),
+    version INT DEFAULT 0,
     approval_comment TEXT,
     attachment_urls TEXT,
     timeout_time TIMESTAMP NULL,
     timeout_action VARCHAR(20),
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     handle_time TIMESTAMP NULL,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_task_instance_id (instance_id),
+    KEY idx_task_assignee_type_status (assignee_id, assignee_type, task_status),
+    KEY idx_task_node (node_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+CREATE INDEX IF NOT EXISTS idx_instance_status_end_definition ON bpm_process_instance(status, end_time, process_definition_id);
+CREATE INDEX IF NOT EXISTS idx_task_status_node_create ON bpm_approval_task(task_status, node_type, create_time);
+CREATE INDEX IF NOT EXISTS idx_task_status_timeout ON bpm_approval_task(task_status, timeout_time);
+
+CREATE TABLE IF NOT EXISTS bpm_attachment (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    instance_id BIGINT NOT NULL COMMENT '流程实例ID',
+    task_id BIGINT COMMENT '关联任务ID',
+    record_id BIGINT COMMENT '关联审批记录ID',
+    node_id VARCHAR(50) COMMENT '节点ID',
+    node_name VARCHAR(100) COMMENT '节点名称',
+    file_name VARCHAR(500) NOT NULL COMMENT '原始文件名',
+    file_url VARCHAR(500) NOT NULL COMMENT '文件访问URL',
+    file_size BIGINT COMMENT '文件大小(字节)',
+    uploader_id BIGINT COMMENT '上传人ID',
+    uploader_name VARCHAR(50) COMMENT '上传人姓名',
+    uploader_type VARCHAR(20) COMMENT '上传人类型:SYSTEM/ROLE',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_attachment_instance_id (instance_id),
+    KEY idx_attachment_task_id (task_id),
+    KEY idx_attachment_record_id (record_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程附件表';
+
 CREATE TABLE IF NOT EXISTS bpm_approval_record (
     id BIGINT PRIMARY KEY AUTO_INCREMENT,
     task_id BIGINT NOT NULL,
@@ -113,5 +150,33 @@ CREATE TABLE IF NOT EXISTS bpm_approval_record (
     comment TEXT,
     attachment_urls TEXT,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_record_instance_id (instance_id),
+    KEY idx_record_task_id (task_id),
+    KEY idx_record_operator (operator_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 兼容已存在的数据库:补充企业微信字段
+ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
+ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';
+
+-- 兼容已存在的数据库:补充角色企业微信字段
+ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
+ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';
+
+-- 兼容已存在的数据库:补充角色手机号字段及唯一索引
+ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS phone VARCHAR(20) COMMENT '手机号';
+CREATE UNIQUE INDEX IF NOT EXISTS uk_role_phone ON sys_role(phone);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_user_phone ON sys_user(phone);
+
+-- 通知配置表(预留:供企业微信/邮件/短信等渠道配置读取)
+CREATE TABLE IF NOT EXISTS sys_notification_config (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
+    config_value VARCHAR(500) COMMENT '配置值',
+    config_type VARCHAR(50) COMMENT '配置类型:WECOM/EMAIL/SMS',
+    description VARCHAR(200) COMMENT '配置说明',
+    status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知配置表';

+ 89 - 24
src/main/resources/schema-mysql.sql

@@ -1,6 +1,6 @@
 -- ========================================================
 -- MySQL 建表脚本(带注释)
--- 基于 schema-test.sql 适配,适用于 MySQL 5.7+
+-- 适用于 MySQL 5.7+
 -- ========================================================
 
 -- ----------------------------
@@ -13,11 +13,14 @@ CREATE TABLE IF NOT EXISTS sys_user (
     real_name VARCHAR(50) COMMENT '真实姓名',
     phone VARCHAR(20) COMMENT '手机号',
     email VARCHAR(100) COMMENT '邮箱',
-    dept_id BIGINT DEFAULT 0 COMMENT '所属部门ID,0表示无部门',
-    employee_type VARCHAR(20) DEFAULT 'common_user' COMMENT '员工类型:common_user-普通用户等',
+    dept_id BIGINT DEFAULT NULL COMMENT '所属部门ID,NULL表示无部门',
+    employee_type VARCHAR(20) DEFAULT 'common_user' COMMENT '员工类型:super_admin/flow_manager/dept_manager/common_user',
     status TINYINT DEFAULT 0 COMMENT '状态:0-正常,1-禁用',
+    wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_user_phone (phone)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
 
 -- ----------------------------
@@ -27,11 +30,17 @@ CREATE TABLE IF NOT EXISTS sys_role (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID',
     role_code VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码',
     role_name VARCHAR(50) NOT NULL COMMENT '角色名称',
-    role_scope VARCHAR(20) DEFAULT 'tenant' COMMENT '角色范围:tenant-租户级',
+    role_scope VARCHAR(20) DEFAULT 'tenant' COMMENT '角色范围:platform-平台级 tenant-租户级',
     parent_id BIGINT DEFAULT 0 COMMENT '父角色ID,0表示顶级角色',
     dept_id BIGINT DEFAULT 0 COMMENT '所属部门ID',
+    username VARCHAR(50) COMMENT '角色账号登录名',
+    phone VARCHAR(20) COMMENT '手机号',
+    password VARCHAR(100) COMMENT '角色账号密码(加密存储)',
     status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
-    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
+    wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_role_phone (phone)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
 
 -- ----------------------------
@@ -59,7 +68,7 @@ CREATE TABLE IF NOT EXISTS sys_dept (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';
 
 -- ----------------------------
--- 7. 流程定义表
+-- 5. 流程定义表
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS bpm_process_definition (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '流程定义ID',
@@ -67,20 +76,21 @@ CREATE TABLE IF NOT EXISTS bpm_process_definition (
     process_name VARCHAR(100) NOT NULL COMMENT '流程名称',
     category VARCHAR(50) COMMENT '流程分类',
     form_id BIGINT COMMENT '关联表单ID',
-    model_json TEXT NOT NULL COMMENT '流程模型JSON(BPMN/Flowable模型)',
+    form_schema TEXT COMMENT '流程表单字段定义 JSON',
+    model_json TEXT NOT NULL COMMENT '流程模型JSON',
     version INT DEFAULT 1 COMMENT '版本号',
-    status TINYINT DEFAULT 0 COMMENT '状态:0-草稿,1-已发布,2-已禁用',
+    status TINYINT DEFAULT 0 COMMENT '状态:0-设计中 1-启用中 2-历史',
     description VARCHAR(500) COMMENT '流程描述',
     create_by BIGINT COMMENT '创建人ID',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     update_by BIGINT COMMENT '更新人ID',
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
-    UNIQUE KEY uk_process_code_version (process_code, version) COMMENT '流程编码+版本唯一索引'
+    UNIQUE KEY uk_process_code_version (process_code, version, deleted) COMMENT '流程编码+版本+删除标记唯一索引'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程定义表';
 
 -- ----------------------------
--- 8. 流程实例表
+-- 6. 流程实例表
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS bpm_process_instance (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '流程实例ID',
@@ -93,39 +103,50 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     form_data TEXT COMMENT '表单数据JSON',
     attachment_urls TEXT COMMENT '附件URL列表JSON',
     current_node_id VARCHAR(50) COMMENT '当前节点ID',
-    status TINYINT DEFAULT 0 COMMENT '实例状态:0-进行中,1-已完成,2-已驳回,3-已撤回',
-    result TINYINT COMMENT '审批结果:0-不通过,1-通过',
+    status TINYINT DEFAULT 0 COMMENT '实例状态:0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
+    result VARCHAR(20) COMMENT '审批结果:PASS/REJECT/RETURN/TRANSFER',
     start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '流程启动时间',
     end_time TIMESTAMP NULL COMMENT '流程结束时间',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_instance_definition_id (process_definition_id),
+    KEY idx_instance_applicant (applicant_id),
+    KEY idx_instance_status (status),
+    KEY idx_instance_current_node (current_node_id),
+    KEY idx_instance_status_end_definition (status, end_time, process_definition_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程实例表';
 
 -- ----------------------------
--- 9. 审批任务表
+-- 7. 审批任务表
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS bpm_approval_task (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID',
     instance_id BIGINT NOT NULL COMMENT '流程实例ID',
     node_id VARCHAR(50) NOT NULL COMMENT '节点ID(流程模型中的节点标识)',
     node_name VARCHAR(100) COMMENT '节点名称',
-    node_type VARCHAR(20) COMMENT '节点类型:user_task-用户任务等',
+    node_type VARCHAR(20) COMMENT '节点类型:start/approval/cc/condition/end',
     assignee_id BIGINT COMMENT '任务处理人ID',
-    assignee_type VARCHAR(20) COMMENT '处理人类型:user-指定用户,role-角色,dept-部门等',
-    task_status TINYINT DEFAULT 0 COMMENT '任务状态:0-待处理,1-已处理,2-已转交',
-    approval_result TINYINT COMMENT '审批结果:0-驳回,1-同意,2-转交',
+    assignee_type VARCHAR(20) COMMENT '处理人类型:USER-指定用户 ROLE-角色 SELF-发起人 LEADER-部门主管',
+    task_status TINYINT DEFAULT 0 COMMENT '任务状态:0待处理 1已处理 2已转办 3已跳过 4已回退',
+    approval_result VARCHAR(20) COMMENT '审批结果:PASS/REJECT/RETURN/TRANSFER',
+    version INT DEFAULT 0 COMMENT '乐观锁版本号',
     approval_comment TEXT COMMENT '审批意见',
     attachment_urls TEXT COMMENT '附件URL列表(JSON数组)',
     timeout_time TIMESTAMP NULL COMMENT '超时时间',
     timeout_action VARCHAR(20) COMMENT '超时动作:pass-自动通过,reject-自动驳回,remind-提醒等',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(任务生成时间)',
     handle_time TIMESTAMP NULL COMMENT '处理时间',
-    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_task_instance_id (instance_id),
+    KEY idx_task_assignee_type_status (assignee_id, assignee_type, task_status),
+    KEY idx_task_node (node_id),
+    KEY idx_task_status_node_create (task_status, node_type, create_time),
+    KEY idx_task_status_timeout (task_status, timeout_time)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务表';
 
 -- ----------------------------
--- 10. 审批记录表
+-- 8. 审批记录表
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS bpm_approval_record (
     id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
@@ -135,10 +156,54 @@ CREATE TABLE IF NOT EXISTS bpm_approval_record (
     node_name VARCHAR(100) COMMENT '节点名称',
     operator_id BIGINT NOT NULL COMMENT '操作人ID',
     operator_name VARCHAR(50) COMMENT '操作人姓名',
-    action_type VARCHAR(20) NOT NULL COMMENT '动作类型:approve-同意,reject-驳回,transfer-转交等',
-    action_result VARCHAR(20) COMMENT '动作结果:同意/驳回/转交等',
+    action_type VARCHAR(20) NOT NULL COMMENT '动作类型:APPROVE/REJECT/RETURN/TRANSFER/ADD_SIGN',
+    action_result VARCHAR(20) COMMENT '动作结果:PASS/REJECT/RETURN/TRANSFER',
     comment TEXT COMMENT '审批意见',
     attachment_urls TEXT COMMENT '附件URL列表(JSON数组)',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
-    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除'
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_record_instance_id (instance_id),
+    KEY idx_record_task_id (task_id),
+    KEY idx_record_operator (operator_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录表(操作日志/历史记录)';
+
+-- ----------------------------
+-- 9. 流程附件表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS bpm_attachment (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '附件ID',
+    instance_id BIGINT NOT NULL COMMENT '流程实例ID',
+    task_id BIGINT COMMENT '关联任务ID',
+    record_id BIGINT COMMENT '关联审批记录ID',
+    node_id VARCHAR(50) COMMENT '节点ID',
+    node_name VARCHAR(100) COMMENT '节点名称',
+    file_name VARCHAR(500) NOT NULL COMMENT '原始文件名',
+    file_url VARCHAR(500) NOT NULL COMMENT '文件访问URL',
+    file_size BIGINT COMMENT '文件大小(字节)',
+    uploader_id BIGINT COMMENT '上传人ID',
+    uploader_name VARCHAR(50) COMMENT '上传人姓名',
+    uploader_type VARCHAR(20) COMMENT '上传人类型:SYSTEM/ROLE',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_attachment_instance_id (instance_id),
+    KEY idx_attachment_task_id (task_id),
+    KEY idx_attachment_record_id (record_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程附件表';
+
+-- 兼容已存在的 MySQL 数据库:补充角色企业微信字段
+ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
+ALTER TABLE sys_role ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';
+
+-- ----------------------------
+-- 9. 通知配置表
+-- ----------------------------
+CREATE TABLE IF NOT EXISTS sys_notification_config (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
+    config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
+    config_value VARCHAR(500) COMMENT '配置值',
+    config_type VARCHAR(50) COMMENT '配置类型:WECOM/EMAIL/SMS',
+    description VARCHAR(200) COMMENT '配置说明',
+    status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知配置表';

+ 59 - 9
src/main/resources/schema-test.sql

@@ -5,9 +5,11 @@ CREATE TABLE IF NOT EXISTS sys_user (
     real_name VARCHAR(50),
     phone VARCHAR(20),
     email VARCHAR(100),
-    dept_id BIGINT DEFAULT 0,
+    dept_id BIGINT DEFAULT NULL,
     employee_type VARCHAR(20) DEFAULT 'common_user',
     status TINYINT DEFAULT 0,
+    wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -19,7 +21,7 @@ CREATE TABLE IF NOT EXISTS sys_role (
     role_scope VARCHAR(20) DEFAULT 'tenant',
     parent_id BIGINT DEFAULT 0,
     dept_id BIGINT DEFAULT 0,
-    username VARCHAR(50),
+    username VARCHAR(50) UNIQUE,
     password VARCHAR(100),
     status TINYINT DEFAULT 1,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -49,16 +51,17 @@ CREATE TABLE IF NOT EXISTS bpm_process_definition (
     process_name VARCHAR(100) NOT NULL,
     category VARCHAR(50),
     form_id BIGINT,
+    form_schema TEXT COMMENT '流程表单字段定义 JSON',
     model_json TEXT NOT NULL,
     version INT DEFAULT 1,
-    status TINYINT DEFAULT 0,
+    status TINYINT DEFAULT 0 COMMENT '0-设计中 1-启用中 2-历史',
     description VARCHAR(500),
     create_by BIGINT,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_by BIGINT,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     deleted TINYINT DEFAULT 0,
-    UNIQUE KEY uk_process_code_version (process_code, version)
+    UNIQUE KEY uk_process_code_version (process_code, version, deleted)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS bpm_process_instance (
@@ -72,13 +75,17 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     form_data TEXT,
     attachment_urls TEXT,
     current_node_id VARCHAR(50),
-    status TINYINT DEFAULT 0,
+    status TINYINT DEFAULT 0 COMMENT '0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
     result VARCHAR(20),
     start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     end_time TIMESTAMP NULL,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_instance_definition_id (process_definition_id),
+    KEY idx_instance_applicant (applicant_id),
+    KEY idx_instance_status (status),
+    KEY idx_instance_current_node (current_node_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS bpm_approval_task (
@@ -89,15 +96,19 @@ CREATE TABLE IF NOT EXISTS bpm_approval_task (
     node_type VARCHAR(20),
     assignee_id BIGINT,
     assignee_type VARCHAR(20),
-    task_status TINYINT DEFAULT 0,
+    task_status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已转办 3已跳过 4已回退',
     approval_result VARCHAR(20),
+    version INT DEFAULT 0,
     approval_comment TEXT,
     attachment_urls TEXT,
     timeout_time TIMESTAMP NULL,
     timeout_action VARCHAR(20),
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     handle_time TIMESTAMP NULL,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_task_instance_id (instance_id),
+    KEY idx_task_assignee_type_status (assignee_id, assignee_type, task_status),
+    KEY idx_task_node (node_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 CREATE TABLE IF NOT EXISTS bpm_approval_record (
@@ -113,5 +124,44 @@ CREATE TABLE IF NOT EXISTS bpm_approval_record (
     comment TEXT,
     attachment_urls TEXT,
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    deleted TINYINT DEFAULT 0
+    deleted TINYINT DEFAULT 0,
+    KEY idx_record_instance_id (instance_id),
+    KEY idx_record_task_id (task_id),
+    KEY idx_record_operator (operator_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS bpm_attachment (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    instance_id BIGINT NOT NULL COMMENT '流程实例ID',
+    task_id BIGINT COMMENT '关联任务ID',
+    record_id BIGINT COMMENT '关联审批记录ID',
+    node_id VARCHAR(50) COMMENT '节点ID',
+    node_name VARCHAR(100) COMMENT '节点名称',
+    file_name VARCHAR(500) NOT NULL COMMENT '原始文件名',
+    file_url VARCHAR(500) NOT NULL COMMENT '文件访问URL',
+    file_size BIGINT COMMENT '文件大小(字节)',
+    uploader_id BIGINT COMMENT '上传人ID',
+    uploader_name VARCHAR(50) COMMENT '上传人姓名',
+    uploader_type VARCHAR(20) COMMENT '上传人类型:SYSTEM/ROLE',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+    deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
+    KEY idx_attachment_instance_id (instance_id),
+    KEY idx_attachment_task_id (task_id),
+    KEY idx_attachment_record_id (record_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程附件表';
+
+-- 兼容已存在的数据库:补充企业微信字段
+ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
+ALTER TABLE sys_user ADD COLUMN IF NOT EXISTS wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';
+
+-- 通知配置表(预留:供企业微信/邮件/短信等渠道配置读取)
+CREATE TABLE IF NOT EXISTS sys_notification_config (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
+    config_value VARCHAR(500) COMMENT '配置值',
+    config_type VARCHAR(50) COMMENT '配置类型:WECOM/EMAIL/SMS',
+    description VARCHAR(200) COMMENT '配置说明',
+    status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知配置表';