Browse Source

Merge branch 'feature/wl-coupon'

wuwenyi 2 tuần trước cách đây
mục cha
commit
f9e1a933cd
35 tập tin đã thay đổi với 996 bổ sung và 40 xóa
  1. 2 0
      Dockerfile
  2. 3 1
      docker-compose.yml
  3. 22 0
      sql/20261019_node_assignees.sql
  4. 5 0
      src/main/java/com/qqflow/engine/QqFlowEngineApplication.java
  5. 2 0
      src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java
  6. 12 0
      src/main/java/com/qqflow/engine/domain/ewechat/api/EnterpriseWechatApi.java
  7. 63 0
      src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java
  8. 80 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendTextCardMsgReq.java
  9. 26 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUserInfoRsp.java
  10. 24 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/WecomOAuthResultRsp.java
  11. 55 10
      src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java
  12. 170 0
      src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java
  13. 6 0
      src/main/java/com/qqflow/engine/domain/flow/controller/ApprovalTaskController.java
  14. 10 0
      src/main/java/com/qqflow/engine/domain/flow/dto/StartProcessDTO.java
  15. 1 0
      src/main/java/com/qqflow/engine/domain/flow/listener/NotificationEventListener.java
  16. 12 2
      src/main/java/com/qqflow/engine/domain/flow/mapper/ProcessDefinitionMapper.java
  17. 8 0
      src/main/java/com/qqflow/engine/domain/flow/po/ProcessInstance.java
  18. 3 0
      src/main/java/com/qqflow/engine/domain/flow/service/ApprovalTaskService.java
  19. 3 1
      src/main/java/com/qqflow/engine/domain/flow/service/NotificationService.java
  20. 123 4
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskServiceImpl.java
  21. 53 2
      src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java
  22. 32 6
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessDefinitionServiceImpl.java
  23. 10 1
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java
  24. 219 8
      src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java
  25. 7 0
      src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java
  26. 8 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java
  27. 5 0
      src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java
  28. 8 0
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java
  29. 7 2
      src/main/resources/application-test.yml
  30. 7 0
      src/main/resources/application.yml
  31. 5 3
      src/main/resources/mapper/flow/ApprovalTaskMapper.xml
  32. 2 0
      src/main/resources/mapper/flow/ProcessInstanceMapper.xml
  33. 1 0
      src/main/resources/schema-dev.sql
  34. 1 0
      src/main/resources/schema-mysql.sql
  35. 1 0
      src/main/resources/schema-test.sql

+ 2 - 0
Dockerfile

@@ -12,6 +12,8 @@ COPY src ./src
 RUN --mount=type=cache,target=/root/.m2/repository mvn package -DskipTests -B -q
 
 FROM eclipse-temurin:17-jre
+# 容器默认 UTC;固定东八区,避免 LocalDateTime.now() 与 TIMESTAMP 读写偏 8 小时
+ENV TZ=Asia/Shanghai
 WORKDIR /app
 COPY --from=build /app/target/*.jar app.jar
 EXPOSE 8080

+ 3 - 1
docker-compose.yml

@@ -10,9 +10,11 @@ services:
     networks:
       - qqflow-network
     environment:
+      TZ: Asia/Shanghai
+      JAVA_TOOL_OPTIONS: -Duser.timezone=Asia/Shanghai
       SPRING_PROFILES_ACTIVE: test
       JWT_SECRET: qqflow-test-secret-key-2024-min-32chars
-      SPRING_DATASOURCE_URL: jdbc:mysql://192.168.110.4:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai&character_set_server=utf8mb4
+      SPRING_DATASOURCE_URL: jdbc:mysql://192.168.110.4:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai&forceConnectionTimeZoneToSession=true&character_set_server=utf8mb4
       SPRING_DATASOURCE_USERNAME: qqflowengine
       SPRING_DATASOURCE_PASSWORD: 123456
       SPRING_REDIS_HOST: 192.168.110.4

+ 22 - 0
sql/20261019_node_assignees.sql

@@ -0,0 +1,22 @@
+-- =====================================================================
+-- qqflow:流程实例增加「外部指定的节点审批人」
+-- 背景:wl-scrm 发起优惠券审批时,按商户/门店解析出具体审批人,
+--       通过 StartProcessDTO.nodeAssignees 传入;节点 assigneeType=EXTERNAL,
+--       用 properties.nodeKey 匹配。
+-- 可重复执行(先查 information_schema 再决定是否 ALTER)。
+-- =====================================================================
+SET @hasCol = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'bpm_process_instance'
+      AND COLUMN_NAME = 'node_assignees');
+
+SET @ddl = IF(@hasCol = 0,
+    'ALTER TABLE `bpm_process_instance`
+        ADD COLUMN `node_assignees` TEXT NULL
+        COMMENT ''外部指定的节点审批人JSON(nodeKey -> 用户ID数组)''
+        AFTER `attachment_urls`',
+    'SELECT ''skip: node_assignees 已存在'' AS upgrade_result');
+
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 5 - 0
src/main/java/com/qqflow/engine/QqFlowEngineApplication.java

@@ -5,12 +5,17 @@ import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
+import java.util.TimeZone;
+
 @EnableScheduling
 @MapperScan("com.qqflow.engine.**.mapper")
 @SpringBootApplication
 public class QqFlowEngineApplication {
 
     public static void main(String[] args) {
+        // 容器/服务器默认 UTC 时 LocalDateTime.now() 写的是 UTC 时间,
+        // 统一固定为东八区,保证审批时间与发起时间一致
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
         SpringApplication.run(QqFlowEngineApplication.class, args);
     }
 }

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

@@ -13,6 +13,8 @@ public final class SecurityConstants {
 
     public static final String ASSIGNEE_TYPE_ROLE = "ROLE";
     public static final String ASSIGNEE_TYPE_USER = "USER";
+    /** 外部系统(如 wl-scrm)在发起时指定审批人,节点上配置 nodeKey 作为匹配键 */
+    public static final String ASSIGNEE_TYPE_EXTERNAL = "EXTERNAL";
 
     public static final String ROLE_SUPER_ADMIN = "super_admin";
     public static final String ROLE_FLOW_MANAGER = "flow_manager";

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

@@ -161,6 +161,18 @@ public class EnterpriseWechatApi {
         return this.commonPost(uri, req, EnterpriseWechatSendTextMsgRsp.class);
     }
 
+    /** 发送应用文本卡片消息(textcard,可点击跳转,响应结构与文本消息一致) */
+    public EnterpriseWechatSendTextMsgRsp sendTextCardMsg(String accessToken, EnterpriseWechatSendTextCardMsgReq req) {
+        String uri = "/cgi-bin/message/send?access_token=" + accessToken;
+        return this.commonPost(uri, req, EnterpriseWechatSendTextMsgRsp.class);
+    }
+
+    /** 网页授权 code 换取成员身份(snsapi_base 静默授权) */
+    public EnterpriseWechatUserInfoRsp getUserInfoByCode(String accessToken, String code) {
+        String uri = "/cgi-bin/auth/getuserinfo?access_token=" + accessToken + "&code=" + code;
+        return this.commonGet(uri, EnterpriseWechatUserInfoRsp.class);
+    }
+
     /** 上传素材文件 */
     public EnterpriseWechatUploadRsp upload(String accessToken, String type, File file) {
         return this.upload(accessToken, type, getResource(file.getPath()), (int) file.length());

+ 63 - 0
src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java

@@ -0,0 +1,63 @@
+package com.qqflow.engine.domain.ewechat.controller;
+
+import com.qqflow.engine.common.Result;
+import com.qqflow.engine.common.exception.BusinessException;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.WecomOAuthResultRsp;
+import com.qqflow.engine.domain.ewechat.service.WecomOAuthService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+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.net.URI;
+
+/**
+ * 企业微信免登(H5 A 入口)
+ * <p>
+ * 两个接口都在 /open-api/** 下(SecurityConfig 已 permitAll):
+ * <ul>
+ *   <li>{@code GET /open-api/wecom/oauth-url?redirect=} → 302 到企微静默授权页;</li>
+ *   <li>{@code GET /open-api/wecom/oauth?code=&state=} → 换取审批系统 JWT。</li>
+ * </ul>
+ *
+ * @author WuWenYi
+ */
+@Slf4j
+@Tag(name = "企业微信免登")
+@RestController
+@RequestMapping("/open-api/wecom")
+@RequiredArgsConstructor
+public class WecomOAuthController {
+
+    private final WecomOAuthService wecomOAuthService;
+
+    @Operation(summary = "生成企微静默授权地址并 302 跳转")
+    @GetMapping("/oauth-url")
+    public ResponseEntity<Void> oauthUrl(@RequestParam("redirect") String redirect) {
+        try {
+            String url = wecomOAuthService.buildAuthorizeUrl(redirect);
+            return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build();
+        } catch (BusinessException e) {
+            log.warn("[企微免登] 生成授权地址失败:{}", e.getMessage());
+            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
+        }
+    }
+
+    @Operation(summary = "企微授权 code 换取审批系统 JWT")
+    @GetMapping("/oauth")
+    public Result<WecomOAuthResultRsp> oauth(@RequestParam("code") String code,
+                                             @RequestParam(value = "state", required = false) String state) {
+        try {
+            return Result.ok(wecomOAuthService.exchange(code, state));
+        } catch (BusinessException e) {
+            log.warn("[企微免登] 登录失败:{}", e.getMessage());
+            return Result.error(e.getCode() == null ? 500 : e.getCode(), e.getMessage());
+        }
+    }
+}

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

@@ -0,0 +1,80 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.req;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 发送应用文本卡片消息请求(textcard,可点击跳转,用于审批待办/催办)
+ */
+@Data
+public class EnterpriseWechatSendTextCardMsgReq {
+
+    /** 指定接收消息的成员,多个用'|'分隔,最多1000个 */
+    private String touser;
+
+    /** 指定接收消息的部门,多个用'|'分隔,最多100个 */
+    private String toparty;
+
+    /** 指定接收消息的标签,多个用'|'分隔,最多100个 */
+    private String totag;
+
+    /** 消息类型,固定为textcard */
+    private String msgtype = "textcard";
+
+    /** 企业应用的id */
+    private Integer agentid;
+
+    /** 卡片内容 */
+    @JsonProperty("textcard")
+    private TextCardContent textcard;
+
+    /** 是否保密消息,0-可分享 1-水印,默认0 */
+    private Integer safe = 0;
+
+    @JsonProperty("enable_id_trans")
+    private Integer enableIdTrans = 0;
+
+    @JsonProperty("enable_duplicate_check")
+    private Integer enableDuplicateCheck = 0;
+
+    @JsonProperty("duplicate_check_interval")
+    private Integer duplicateCheckInterval = 1800;
+
+    @Data
+    public static class TextCardContent {
+
+        /** 标题,不超过128字节 */
+        private String title;
+
+        /** 描述,不超过512字节(支持 &lt;div class="gray"&gt; 等标签) */
+        private String description;
+
+        /** 点击跳转链接(http/https) */
+        private String url;
+
+        /** 按钮文字,不超过4个汉字 */
+        private String btntxt;
+
+        public static TextCardContent of(String title, String description, String url, String btntxt) {
+            TextCardContent content = new TextCardContent();
+            content.setTitle(title);
+            content.setDescription(description);
+            content.setUrl(url);
+            content.setBtntxt(btntxt);
+            return content;
+        }
+    }
+
+    /** 创建发送给指定用户列表的文本卡片消息 */
+    public static EnterpriseWechatSendTextCardMsgReq toUsers(List<String> userIds, Integer agentId,
+                                                             String title, String description, String url, String btnText) {
+        EnterpriseWechatSendTextCardMsgReq req = new EnterpriseWechatSendTextCardMsgReq();
+        req.setTouser(userIds.stream().collect(Collectors.joining("|")));
+        req.setAgentid(agentId);
+        req.setTextcard(TextCardContent.of(title, description, url, btnText));
+        return req;
+    }
+}

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

@@ -0,0 +1,26 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * 网页授权 code 换取成员身份响应(cgi-bin/auth/getuserinfo)
+ * <p>
+ * snsapi_base 静默授权:返回 userId(成员)或 openid(非本企业成员)。
+ */
+@Getter
+@Setter
+public class EnterpriseWechatUserInfoRsp extends EnterpriseWechatBaseRsp {
+
+    /** 成员userid(旧接口字段名为 UserId,做兼容) */
+    @JsonAlias({"UserId"})
+    private String userid;
+
+    /** 非本企业成员的openid */
+    private String openid;
+
+    private String user_ticket;
+
+    private String external_userid;
+}

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

@@ -0,0 +1,24 @@
+package com.qqflow.engine.domain.ewechat.model.enterprise.rsp;
+
+import lombok.Data;
+
+/**
+ * 企微免登结果(H5 A 入口:code 换 JWT)
+ * <p>
+ * 契约见 qqflowengine-h5/docs/交接文档 §1.2。
+ */
+@Data
+public class WecomOAuthResultRsp {
+
+    /** 审批系统 JWT,后续请求放 Authorization: Bearer */
+    private String token;
+
+    private Long userId;
+
+    private String realName;
+
+    private String deptName;
+
+    /** 企微 userid */
+    private String wecomUserId;
+}

+ 55 - 10
src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java

@@ -5,7 +5,9 @@ import com.qqflow.engine.common.service.RedissonService;
 import com.qqflow.engine.domain.ewechat.api.EnterpriseWechatApi;
 import com.qqflow.engine.domain.ewechat.config.EnterpriseWechatProperties;
 import com.qqflow.engine.domain.ewechat.mapper.EnterpriseWechatCorpMapper;
+import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendTextCardMsgReq;
 import com.qqflow.engine.domain.ewechat.model.enterprise.req.EnterpriseWechatSendTextMsgReq;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatSendTextMsgRsp;
 import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
 import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.entity.SysUserRole;
@@ -39,24 +41,62 @@ public class EnterpriseWechatMsgService {
     /** access_token 有效期 7200 秒,缓存 7000 秒留余量 */
     private static final Duration ACCESS_TOKEN_TTL = Duration.ofSeconds(7000);
 
-    /** 给指定企微用户ID列表发送文本消息 */
-    public void sendTextToUsers(List<String> wecomUserIds, String content) {
-        if (wecomUserIds == null || wecomUserIds.isEmpty()) return;
+    /** 给指定企微用户ID列表发送文本消息;返回是否被企微接口成功接收 */
+    public boolean sendTextToUsers(List<String> wecomUserIds, String content) {
+        if (wecomUserIds == null || wecomUserIds.isEmpty()) return false;
         String accessToken = this.getCorpAccessToken();
         if (accessToken == null) {
             log.warn("[企微消息] 无法获取 access_token");
-            return;
+            return false;
         }
         try {
             EnterpriseWechatSendTextMsgReq req = EnterpriseWechatSendTextMsgReq.toUsers(
                     wecomUserIds, wechatProperties.getAgentId(), content);
-            enterpriseWechatApi.sendTextMsg(accessToken, req);
-            log.info("[企微消息] 已发送 | 接收人数:{}", wecomUserIds.size());
+            EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextMsg(accessToken, req);
+            return logSendResult(rsp, "文本消息", wecomUserIds.size());
         } catch (Exception e) {
             log.error("[企微消息] 发送失败", e);
+            return false;
         }
     }
 
+    /** 给指定企微用户ID列表发送文本卡片消息(可点击跳转,用于审批待办/催办);返回是否发送成功 */
+    public boolean sendTextCardToUsers(List<String> wecomUserIds, String title, String description, String url, String btnText) {
+        if (wecomUserIds == null || wecomUserIds.isEmpty()) return false;
+        String accessToken = this.getCorpAccessToken();
+        if (accessToken == null) {
+            log.warn("[企微消息] 无法获取 access_token");
+            return false;
+        }
+        try {
+            EnterpriseWechatSendTextCardMsgReq req = EnterpriseWechatSendTextCardMsgReq.toUsers(
+                    wecomUserIds, wechatProperties.getAgentId(), title, description, url, btnText);
+            EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextCardMsg(accessToken, req);
+            return logSendResult(rsp, "文本卡片", wecomUserIds.size());
+        } catch (Exception e) {
+            log.error("[企微消息] 文本卡片发送失败", e);
+            return false;
+        }
+    }
+
+    /**
+     * 记录企微接口真实返回码:errcode=0 才算成功。
+     * 之前只看 HTTP 异常,errcode 非 0(如 60020 IP 不在白名单)会被当成发送成功。
+     */
+    private boolean logSendResult(EnterpriseWechatSendTextMsgRsp rsp, String scene, int userCount) {
+        if (rsp == null) {
+            log.error("[企微消息] {}无响应 | 接收人数:{}", scene, userCount);
+            return false;
+        }
+        if (rsp.getErrcode() == null || rsp.getErrcode() == 0) {
+            log.info("[企微消息] {}已发送 | 接收人数:{} | msgid:{}", scene, userCount, rsp.getMsgid());
+            return true;
+        }
+        log.error("[企微消息] {}发送失败 | 接收人数:{} | errcode={} errmsg={} invaliduser={}",
+                scene, userCount, rsp.getErrcode(), rsp.getErrmsg(), rsp.getInvaliduser());
+        return false;
+    }
+
     /** 给指定角色下的所有企微用户发送文本消息 */
     public void sendTextToRole(Long roleId, String content) {
         List<String> wecomUserIds = this.getRoleWecomUserIds(roleId);
@@ -83,15 +123,20 @@ public class EnterpriseWechatMsgService {
                 .collect(Collectors.toList());
     }
 
-    /** 获取企业 access_token(Redis 缓存,TTL 7000s) */
-    public String getCorpAccessToken() {
+    /** 当前已授权的代开发企业(取第一条有永久授权码的,单企业部署场景) */
+    public EnterpriseWechatCorp getAuthorizedCorp() {
         List<EnterpriseWechatCorp> corps = wechatCorpMapper.selectList(
                 new LambdaQueryWrapper<EnterpriseWechatCorp>()
                         .eq(EnterpriseWechatCorp::getSuiteId, wechatProperties.getSuiteId())
                         .isNotNull(EnterpriseWechatCorp::getPermanentCode)
                         .last("LIMIT 1"));
-        if (corps.isEmpty()) return null;
-        EnterpriseWechatCorp corp = corps.get(0);
+        return corps.isEmpty() ? null : corps.get(0);
+    }
+
+    /** 获取企业 access_token(Redis 缓存,TTL 7000s) */
+    public String getCorpAccessToken() {
+        EnterpriseWechatCorp corp = this.getAuthorizedCorp();
+        if (corp == null) return null;
 
         String cacheKey = ACCESS_TOKEN_CACHE_KEY + corp.getCorpId();
         String cached = redissonService.get(cacheKey);

+ 170 - 0
src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java

@@ -0,0 +1,170 @@
+package com.qqflow.engine.domain.ewechat.service;
+
+import com.qqflow.engine.common.exception.BusinessException;
+import com.qqflow.engine.common.util.JwtUtils;
+import com.qqflow.engine.common.util.RedisCache;
+import com.qqflow.engine.config.security.LoginUser;
+import com.qqflow.engine.domain.ewechat.api.EnterpriseWechatApi;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.EnterpriseWechatUserInfoRsp;
+import com.qqflow.engine.domain.ewechat.model.enterprise.rsp.WecomOAuthResultRsp;
+import com.qqflow.engine.domain.ewechat.po.EnterpriseWechatCorp;
+import com.qqflow.engine.domain.system.entity.SysDept;
+import com.qqflow.engine.domain.system.entity.SysUser;
+import com.qqflow.engine.domain.system.mapper.SysDeptMapper;
+import com.qqflow.engine.domain.system.mapper.SysUserMapper;
+import com.qqflow.engine.domain.system.service.SysUserService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYSTEM;
+
+/**
+ * 企业微信网页授权免登(H5 A 入口)
+ *
+ * <p>流程:
+ * <ol>
+ *   <li>H5 调 {@link #buildAuthorizeUrl(String)} 对应的接口 → 302 到企微 snsapi_base 静默授权;</li>
+ *   <li>企微回调 H5,带 code + state;</li>
+ *   <li>H5 调 {@link #exchange(String, String)}:校验一次性 state → code 换 userid →
+ *       匹配 sys_user.wecom_user_id → 签发与 PC 端同款的 JWT。</li>
+ * </ol>
+ *
+ * <p>安全:
+ * <ul>
+ *   <li>state 随机生成并存 Redis(10 分钟、一次性),防登录 CSRF;</li>
+ *   <li>redirect 必须命中 {@code wecom.h5-base-url} 配置的地址前缀(逗号分隔),防开放重定向;</li>
+ *   <li>code 由企微保证一次性/5 分钟有效。</li>
+ * </ul>
+ *
+ * @author WuWenYi
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WecomOAuthService {
+
+    private static final String AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
+
+    private static final String STATE_KEY_PREFIX = "wecom:oauth:state:";
+
+    private static final long STATE_TTL_MINUTES = 10L;
+
+    private final EnterpriseWechatMsgService wechatMsgService;
+    private final EnterpriseWechatApi enterpriseWechatApi;
+    private final SysUserMapper sysUserMapper;
+    private final SysDeptMapper sysDeptMapper;
+    private final SysUserService sysUserService;
+    private final JwtUtils jwtUtils;
+    private final RedisCache redisCache;
+
+    /**
+     * 允许的 H5 地址前缀(逗号分隔;第一个同时作为审批消息 textcard 的跳转地址)
+     */
+    @Value("${wecom.h5-base-url:}")
+    private String h5BaseUrlConfig;
+
+    /**
+     * 生成企微静默授权跳转地址(带一次性 state)
+     */
+    public String buildAuthorizeUrl(String redirect) {
+        validateRedirect(redirect);
+        EnterpriseWechatCorp corp = wechatMsgService.getAuthorizedCorp();
+        if (corp == null) {
+            throw new BusinessException("企业微信未授权,请联系管理员");
+        }
+        String state = UUID.randomUUID().toString().replace("-", "");
+        redisCache.setCacheObject(STATE_KEY_PREFIX + state, redirect, STATE_TTL_MINUTES, TimeUnit.MINUTES);
+        return AUTHORIZE_URL
+                + "?appid=" + corp.getCorpId()
+                + "&redirect_uri=" + URLEncoder.encode(redirect, StandardCharsets.UTF_8)
+                + "&response_type=code&scope=snsapi_base"
+                + "&state=" + state
+                + "#wechat_redirect";
+    }
+
+    /**
+     * 企微 code 换审批系统 JWT(H5 A 入口核心)
+     */
+    public WecomOAuthResultRsp exchange(String code, String state) {
+        if (!StringUtils.hasText(code)) {
+            throw new BusinessException(40002, "授权码为空,请重新进入");
+        }
+        // state 一次性校验(防登录 CSRF):生成时写入 Redis,回调时取出并删除
+        String stateKey = STATE_KEY_PREFIX + state;
+        if (!StringUtils.hasText(state) || redisCache.getCacheObject(stateKey) == null) {
+            throw new BusinessException(40002, "授权状态已失效,请重新进入");
+        }
+        redisCache.deleteObject(stateKey);
+
+        String accessToken = wechatMsgService.getCorpAccessToken();
+        if (accessToken == null) {
+            throw new BusinessException("企业微信未授权,请联系管理员");
+        }
+        EnterpriseWechatUserInfoRsp userInfo = enterpriseWechatApi.getUserInfoByCode(accessToken, code);
+        if (userInfo == null || !StringUtils.hasText(userInfo.getUserid())) {
+            throw new BusinessException(40001, "该企微账号未绑定审批系统账号");
+        }
+        SysUser user = sysUserMapper.selectByWecomUserId(userInfo.getUserid());
+        if (user == null || (user.getStatus() != null && user.getStatus() != 0)) {
+            throw new BusinessException(40001, "该企微账号未绑定审批系统账号");
+        }
+
+        WecomOAuthResultRsp rsp = new WecomOAuthResultRsp();
+        rsp.setToken(jwtUtils.generateToken(buildLoginUser(user)));
+        rsp.setUserId(user.getId());
+        rsp.setRealName(user.getRealName());
+        rsp.setWecomUserId(user.getWecomUserId());
+        if (user.getDeptId() != null) {
+            SysDept dept = sysDeptMapper.selectById(user.getDeptId());
+            rsp.setDeptName(dept == null ? null : dept.getDeptName());
+        }
+        log.info("[企微免登] 登录成功 | wecomUserId:{} | userId:{}", user.getWecomUserId(), user.getId());
+        return rsp;
+    }
+
+    /**
+     * 与 {@code SysUserServiceImpl#loadUserByUsername} 同口径构建登录主体,保证 JWT 里的角色/权限一致
+     */
+    private LoginUser buildLoginUser(SysUser user) {
+        LoginUser loginUser = new LoginUser();
+        loginUser.setUserId(user.getId());
+        loginUser.setUsername(user.getUsername());
+        loginUser.setRealName(user.getRealName());
+        loginUser.setDeptId(user.getDeptId());
+        loginUser.setEmployeeType(user.getEmployeeType());
+        loginUser.setUserType(USER_TYPE_SYSTEM);
+        loginUser.setRoles(sysUserService.loadUserRoles(user.getId()));
+        return loginUser;
+    }
+
+    /**
+     * redirect 必须命中配置的 H5 地址前缀(逗号分隔,支持多环境),防开放重定向
+     */
+    private void validateRedirect(String redirect) {
+        if (!StringUtils.hasText(redirect)
+                || !(redirect.startsWith("http://") || redirect.startsWith("https://"))) {
+            throw new BusinessException("redirect 参数不合法");
+        }
+        if (!StringUtils.hasText(h5BaseUrlConfig)) {
+            throw new BusinessException("未配置 H5 地址(wecom.h5-base-url),无法发起免登");
+        }
+        List<String> allowed = Arrays.stream(h5BaseUrlConfig.split(","))
+                .map(String::trim)
+                .filter(StringUtils::hasText)
+                .toList();
+        boolean matched = allowed.stream().anyMatch(redirect::startsWith);
+        if (!matched) {
+            throw new BusinessException("redirect 地址不在白名单内");
+        }
+    }
+}

+ 6 - 0
src/main/java/com/qqflow/engine/domain/flow/controller/ApprovalTaskController.java

@@ -59,6 +59,12 @@ public class ApprovalTaskController {
         return Result.ok(this.approvalTaskService.handledList(assigneeId, assigneeType, processName, pageNum, pageSize));
     }
 
+    @GetMapping("/{taskId}")
+    @Operation(summary = "审批任务详情(含实例表单快照)")
+    public Result<ApprovalTaskDTO> detail(@PathVariable Long taskId) {
+        return Result.ok(this.approvalTaskService.getDetail(taskId));
+    }
+
     @GetMapping("/{taskId}/next-nodes")
     @Operation(summary = "获取当前任务的下游节点列表(条件节点选分支用)")
     public Result<List<NextNodeDTO>> nextNodes(@PathVariable Long taskId) {

+ 10 - 0
src/main/java/com/qqflow/engine/domain/flow/dto/StartProcessDTO.java

@@ -4,6 +4,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 
+import java.util.List;
+import java.util.Map;
+
 @Data
 @Schema(description = "发起流程DTO")
 public class StartProcessDTO {
@@ -26,4 +29,11 @@ public class StartProcessDTO {
 
     @Schema(description = "指定实例编号(导入时使用),为空则自动生成")
     private String instanceNo;
+
+    /**
+     * 外部系统指定的节点审批人:key = 流程节点上配置的 nodeKey,value = qqflow 用户ID列表。
+     * <p>仅对 assigneeType=EXTERNAL 的审批节点生效;未传时按流程节点原有配置解析。</p>
+     */
+    @Schema(description = "外部系统指定的节点审批人(nodeKey -> 用户ID列表)")
+    private Map<String, List<Long>> nodeAssignees;
 }

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

@@ -24,6 +24,7 @@ public class NotificationEventListener {
     public void onTaskAssigned(TaskAssignedEvent event) {
         try {
             notificationService.notifyTaskAssigned(
+                    event.getInstanceId(),
                     event.getAssigneeIds(),
                     event.getAssigneeType(),
                     event.getProcessName(),

+ 12 - 2
src/main/java/com/qqflow/engine/domain/flow/mapper/ProcessDefinitionMapper.java

@@ -30,16 +30,26 @@ public interface ProcessDefinitionMapper extends BaseMapper<ProcessDefinition> {
         return this.selectOne(wrapper);
     }
 
-    default List<ProcessDefinition> selectEnabledList() {
+    /**
+     * 启用中的流程列表;ownerId 非空时只返回该创建人的流程(商户隔离)。
+     */
+    default List<ProcessDefinition> selectEnabledList(Long ownerId) {
         LambdaQueryWrapper<ProcessDefinition> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(ProcessDefinition::getStatus, 1);
+        if (ownerId != null) {
+            wrapper.eq(ProcessDefinition::getCreateBy, ownerId);
+        }
         return this.selectList(wrapper);
     }
 
-    default Integer selectMaxVersion(String processCode) {
+    /**
+     * 同创建人、同编码的最新版本号;商户之间编码命名空间互相独立。
+     */
+    default Integer selectMaxVersion(String processCode, Long ownerId) {
         LambdaQueryWrapper<ProcessDefinition> wrapper = new LambdaQueryWrapper<>();
         wrapper.select(ProcessDefinition::getVersion)
                 .eq(ProcessDefinition::getProcessCode, processCode)
+                .eq(ProcessDefinition::getCreateBy, ownerId)
                 .orderByDesc(ProcessDefinition::getVersion)
                 .last("limit 1");
         ProcessDefinition result = this.selectOne(wrapper);

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

@@ -51,6 +51,14 @@ public class ProcessInstance {
     @Schema(description = "附件URL列表JSON")
     private String attachmentUrls;
 
+    /**
+     * 外部系统指定的节点审批人 JSON:{ "节点nodeKey": [用户ID...] }
+     * <p>由发起方传入,持久化后供后续节点(包括审批推进后的节点)解析使用。</p>
+     */
+    @TableField("node_assignees")
+    @Schema(description = "外部指定的节点审批人JSON")
+    private String nodeAssignees;
+
     @TableField("current_node_id")
     @Schema(description = "当前节点ID")
     private String currentNodeId;

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/service/ApprovalTaskService.java

@@ -17,6 +17,9 @@ public interface ApprovalTaskService {
 
     PageResult<ApprovalTaskDTO> handledList(Long assigneeId, String assigneeType, String processName, Integer pageNum, Integer pageSize);
 
+    /** 任务详情:任务 + 实例表单数据/流程名称(H5/PC 审批详情页用) */
+    ApprovalTaskDTO getDetail(Long taskId);
+
     void approve(ApproveTaskDTO dto);
 
     void reject(ApproveTaskDTO dto);

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

@@ -13,13 +13,15 @@ public interface NotificationService {
     /**
      * 发送任务分配通知(待我处理)
      *
+     * @param instanceId    实例ID(用于读取表单快照,生成券审批卡片内容)
      * @param assigneeIds   接收人ID列表
      * @param assigneeType  接收人类型(USER / ROLE)
      * @param processName   流程名称
      * @param instanceTitle 实例标题
      * @param nodeName      当前节点名称
      */
-    void notifyTaskAssigned(List<Long> assigneeIds, String assigneeType, String processName, String instanceTitle, String nodeName);
+    void notifyTaskAssigned(Long instanceId, List<Long> assigneeIds, String assigneeType,
+                            String processName, String instanceTitle, String nodeName);
 
     /**
      * 发送任务完成通知(审批结果通知)

+ 123 - 4
src/main/java/com/qqflow/engine/domain/flow/service/impl/ApprovalTaskServiceImpl.java

@@ -57,9 +57,11 @@ import java.io.File;
 import java.time.LocalDateTime;
 import java.time.temporal.ChronoUnit;
 import java.util.Collections;
+import java.util.LinkedHashSet;
 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;
@@ -148,6 +150,46 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
     }
 
     @Override
+    public ApprovalTaskDTO getDetail(Long taskId) {
+        ApprovalTask task = this.approvalTaskMapper.selectById(taskId);
+        if (task == null || Integer.valueOf(1).equals(task.getDeleted())) {
+            throw new BusinessException("任务不存在");
+        }
+        ProcessInstance instance = this.processInstanceMapper.selectById(task.getInstanceId());
+        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 = this.isApplicantOrAdmin(instance, loginUser, userId, userType)
+                || this.isTaskAssignee(instance.getId(), userId, userType)
+                || this.isRecordOperator(instance.getId(), userId, userType);
+        if (!visible) {
+            throw new BusinessException("无权查看该审批");
+        }
+        ApprovalTaskDTO dto = ApprovalTaskDTO.of(task);
+        dto.setInstanceTitle(instance.getTitle());
+        dto.setInstanceNo(instance.getInstanceNo());
+        // 详情页的表单/券快照来自实例,不能只依赖列表查询字段
+        dto.setFormData(instance.getFormData());
+        ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
+        if (definition != null) {
+            dto.setDefinitionName(definition.getProcessName());
+            dto.setFormSchema(definition.getFormSchema());
+        }
+        this.fillUrgency(dto);
+        List<ApprovalTaskDTO> records = Collections.singletonList(dto);
+        this.approvalTaskDTOAssembler.fillAssigneeName(records);
+        this.approvalTaskDTOAssembler.fillSubNodeName(records);
+        return dto;
+    }
+
+    @Override
     @Transactional
     public void approve(ApproveTaskDTO dto) {
         ApprovalTask task = this.getPendingTaskForUpdate(dto.getTaskId());
@@ -229,7 +271,7 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         ProcessInstance instance = this.getActiveInstance(task.getInstanceId());
         Long operatorId = SecurityUtils.getUserId();
         Long transferToUserId = dto.getTransferToUserId() != null ? dto.getTransferToUserId() : dto.getTransferTo();
-        this.validateTransferTarget(transferToUserId, operatorId);
+        this.validateTransferTarget(transferToUserId, operatorId, instance);
         this.saveRecord(task, instance, operatorId, ApprovalAction.TRANSFER, ApprovalResult.TRANSFER.getCode(), dto.getComment(), null);
         this.updateTaskToTransferred(task);
         this.createTransferTask(task, instance, transferToUserId);
@@ -244,20 +286,20 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         if (assigneeId == null) {
             throw new BusinessException("加签人不能为空");
         }
-        this.validateTransferTarget(assigneeId, null);
+        ProcessInstance instance = this.processInstanceMapper.selectById(task.getInstanceId());
+        this.validateTransferTarget(assigneeId, null, instance);
         ApprovalTask newTask = this.approvalTaskAssembler.buildNew(
                 task.getInstanceId(), task.getNodeId(), task.getNodeName(),
                 task.getNodeType(), assigneeId, ASSIGNEE_TYPE_USER, TaskStatus.PENDING.getCode()
         );
         this.approvalTaskMapper.insert(newTask);
-        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) {
+    private void validateTransferTarget(Long targetUserId, Long operatorId, ProcessInstance instance) {
         if (targetUserId == null) {
             throw new BusinessException("转办/加签人不能为空");
         }
@@ -271,6 +313,12 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         if (targetUser.getStatus() != null && targetUser.getStatus() != 0) {
             throw new BusinessException("转办/加签人已被禁用");
         }
+        // 外部系统发起的流程(如优惠券审批)只允许在本流程审批人范围内转办/加签,
+        // 防止绕开选人接口直接指定 QQFlow 内部或其他商户人员
+        if (instance != null && this.isExternalInstance(instance)
+                && !this.resolveInstanceApproverIds(instance).contains(targetUserId)) {
+            throw new BusinessException("只能转办/加签给本流程的审批人");
+        }
     }
 
     @Override
@@ -643,6 +691,11 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
         if (instance == null) {
             throw new BusinessException("流程实例不存在");
         }
+        // 外部系统发起的流程(如优惠券审批):候选池收敛到本实例的审批人,
+        // 避免把 QQFlow 内部人员、其他商户审批人暴露给外部审批方
+        if (this.isExternalInstance(instance)) {
+            return this.listInstanceApprovers(instance, operatorId);
+        }
         ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
         if (definition == null || definition.getModelJson() == null) {
             return this.listAllActiveSystemUsers(operatorId);
@@ -723,4 +776,70 @@ public class ApprovalTaskServiceImpl implements ApprovalTaskService {
                 .map(UserDTO::of)
                 .collect(Collectors.toList());
     }
+
+    /**
+     * 是否外部系统发起的流程:发起时由外部系统指定了各节点审批人(nodeAssignees)。
+     * <p>目前仅 wl-scrm 优惠券审批使用该字段,用于对这类流程做人员范围收敛。</p>
+     */
+    private boolean isExternalInstance(ProcessInstance instance) {
+        return StringUtils.hasText(instance.getNodeAssignees());
+    }
+
+    /**
+     * 流程实例内的审批人集合:发起时配置的各节点审批人 + 本实例已产生任务的接收人。
+     * <p>带上任务接收人是为了保证被转办/加签的人仍能在流程内继续流转。</p>
+     */
+    private Set<Long> resolveInstanceApproverIds(ProcessInstance instance) {
+        Set<Long> userIds = new LinkedHashSet<>();
+        if (StringUtils.hasText(instance.getNodeAssignees())) {
+            try {
+                Map<String, List<Long>> assignees = this.objectMapper.readValue(
+                        instance.getNodeAssignees(), new TypeReference<Map<String, List<Long>>>() {
+                        });
+                assignees.values().stream()
+                        .filter(Objects::nonNull)
+                        .forEach(userIds::addAll);
+            } catch (Exception e) {
+                throw new BusinessException("解析外部指定审批人失败:" + e.getMessage());
+            }
+        }
+        List<ApprovalTask> tasks = this.approvalTaskMapper.selectList(
+                new LambdaQueryWrapper<ApprovalTask>()
+                        .eq(ApprovalTask::getInstanceId, instance.getId()));
+        for (ApprovalTask task : tasks) {
+            // ROLE 任务的 assigneeId 是角色ID,不能当作用户ID
+            if (ASSIGNEE_TYPE_USER.equals(task.getAssigneeType()) && task.getAssigneeId() != null) {
+                userIds.add(task.getAssigneeId());
+            }
+        }
+        return userIds;
+    }
+
+    /**
+     * 本流程审批人列表:仅返回选人必要字段,不暴露手机号、邮箱等敏感信息。
+     */
+    private List<UserDTO> listInstanceApprovers(ProcessInstance instance, Long excludeUserId) {
+        Set<Long> userIds = this.resolveInstanceApproverIds(instance);
+        if (userIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<SysUser> users = this.sysUserMapper.selectList(
+                new LambdaQueryWrapper<SysUser>()
+                        .in(SysUser::getId, userIds)
+                        .eq(SysUser::getStatus, 0)
+                        .orderByAsc(SysUser::getRealName, SysUser::getId));
+        return users.stream()
+                .filter(u -> excludeUserId == null || !u.getId().equals(excludeUserId))
+                .map(this::buildPickerUser)
+                .collect(Collectors.toList());
+    }
+
+    private UserDTO buildPickerUser(SysUser user) {
+        UserDTO dto = new UserDTO();
+        dto.setId(user.getId());
+        dto.setUsername(user.getUsername());
+        dto.setRealName(user.getRealName());
+        dto.setDeptId(user.getDeptId());
+        return dto;
+    }
 }

+ 53 - 2
src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java

@@ -54,6 +54,7 @@ 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_EXTERNAL;
 import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_ROLE;
 import static com.qqflow.engine.common.constant.SecurityConstants.ASSIGNEE_TYPE_USER;
 
@@ -709,7 +710,15 @@ public class FlowEngineServiceImpl implements FlowEngineService {
 
             List<Long> assignees;
             String taskAssigneeType;
-            if (ASSIGNEE_TYPE_ROLE.equals(assigneeType)) {
+            if (ASSIGNEE_TYPE_EXTERNAL.equals(assigneeType)) {
+                // 外部系统(wl-scrm)发起时按 nodeKey 指定审批人;未指定视为配置错误,不能静默回退到发起人
+                assignees = this.resolveExternalAssignees(instance, node);
+                if (assignees.isEmpty()) {
+                    throw new BusinessException("节点【" + node.getName() + "】未指定审批人(nodeKey="
+                            + getNodeKey(node) + ")");
+                }
+                taskAssigneeType = ASSIGNEE_TYPE_USER;
+            } else 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();
@@ -719,7 +728,7 @@ public class FlowEngineServiceImpl implements FlowEngineService {
                 assignees = this.calculateAssignees(node, instance);
                 taskAssigneeType = ASSIGNEE_TYPE_USER;
             }
-            if (assignees.isEmpty()) {
+            if (!ASSIGNEE_TYPE_EXTERNAL.equals(assigneeType) && assignees.isEmpty()) {
                 assignees = Collections.singletonList(instance.getApplicantId());
                 taskAssigneeType = ASSIGNEE_TYPE_USER;
             }
@@ -881,6 +890,10 @@ public class FlowEngineServiceImpl implements FlowEngineService {
 
     private void createSubNodeTasks(ProcessInstance instance, FlowNode node, FlowModel model,
                                     Set<String> visited, int depth, boolean allowRecreate) {
+        // 外部指定审批人依赖 nodeKey 精确匹配;子节点有独立的审批人配置,V1 不混用
+        if (ASSIGNEE_TYPE_EXTERNAL.equals(getNodeAssigneeTypeRaw(node))) {
+            throw new BusinessException("节点【" + node.getName() + "】使用外部指定审批人时不支持子节点,请改用普通审批节点");
+        }
         List<SubNodeConfig> subNodes = this.extractSubNodes(node);
         if (subNodes.isEmpty()) {
             return;
@@ -949,6 +962,44 @@ public class FlowEngineServiceImpl implements FlowEngineService {
     }
 
     /**
+     * 读取外部系统传入的节点审批人:{@code instance.nodeAssignees = { nodeKey: [userId...] }}。
+     * <p>节点上必须配置 {@code properties.nodeKey},与发起方传入的 key 一致。</p>
+     */
+    private List<Long> resolveExternalAssignees(ProcessInstance instance, FlowNode node) {
+        String nodeKey = this.getNodeKey(node);
+        if (nodeKey == null || instance.getNodeAssignees() == null || instance.getNodeAssignees().isBlank()) {
+            return Collections.emptyList();
+        }
+        try {
+            Map<String, List<Long>> assignees = OBJECT_MAPPER.readValue(
+                    instance.getNodeAssignees(), new TypeReference<Map<String, List<Long>>>() {
+                    });
+            List<Long> userIds = assignees.get(nodeKey);
+            return userIds == null ? Collections.emptyList() : userIds;
+        } catch (Exception e) {
+            throw new BusinessException("解析外部指定审批人失败:" + e.getMessage());
+        }
+    }
+
+    private String getNodeKey(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return null;
+        }
+        Object nodeKey = props.get("nodeKey");
+        return nodeKey == null ? null : nodeKey.toString();
+    }
+
+    private String getNodeAssigneeTypeRaw(FlowNode node) {
+        Map<String, Object> props = node.getProperties();
+        if (props == null) {
+            return ASSIGNEE_TYPE_ROLE;
+        }
+        Object typeObj = props.get("assigneeType");
+        return typeObj == null ? ASSIGNEE_TYPE_ROLE : typeObj.toString();
+    }
+
+    /**
      * 处理子节点审批通过。
      * 返回 true 表示父节点已可继续推进主流程;false 表示仍需等待其他子节点。
      */

+ 32 - 6
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessDefinitionServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 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.domain.flow.dto.ProcessDefinitionDTO;
 import com.qqflow.engine.domain.flow.enums.DefinitionStatus;
 import com.qqflow.engine.domain.flow.enums.ProcessStatus;
@@ -19,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 @Service
@@ -32,8 +34,10 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     @Transactional(rollbackFor = Exception.class)
     public Long saveDefinition(ProcessDefinition po) {
         // 检查是否已存在设计中的同编码流程(防止新建时编码冲突)
+        // 商户之间流程互相隔离,编码唯一性只在同创建人范围内校验
         LambdaQueryWrapper<ProcessDefinition> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(ProcessDefinition::getProcessCode, po.getProcessCode())
+                .eq(ProcessDefinition::getCreateBy, po.getCreateBy())
                 .eq(ProcessDefinition::getStatus, DefinitionStatus.DESIGNING.getCode())
                 .eq(ProcessDefinition::getDeleted, 0);
         Long existingCount = this.processDefinitionMapper.selectCount(wrapper);
@@ -51,6 +55,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         if (existing == null) {
             throw new BusinessException("流程定义不存在");
         }
+        this.checkOwner(existing);
         // 只有 modelJson(流程设计)真正变化 + 非设计状态 才创建新版本
         // formSchema、元信息等变化直接更新
         boolean modelChanged = po.getModelJson() != null && !po.getModelJson().equals(existing.getModelJson());
@@ -82,7 +87,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         newDef.setBizFieldName(po.getBizFieldName() != null ? po.getBizFieldName() : existing.getBizFieldName());
         newDef.setModelJson(po.getModelJson());
         newDef.setDescription(po.getDescription());
-        Integer maxVersion = this.processDefinitionMapper.selectMaxVersion(po.getProcessCode());
+        Integer maxVersion = this.processDefinitionMapper.selectMaxVersion(po.getProcessCode(), existing.getCreateBy());
         newDef.setVersion((maxVersion != null ? maxVersion : existing.getVersion()) + 1);
         newDef.setStatus(DefinitionStatus.DESIGNING.getCode());
         newDef.setCreateBy(existing.getCreateBy());
@@ -97,6 +102,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         if (existing == null) {
             throw new BusinessException("流程定义不存在");
         }
+        this.checkOwner(existing);
         if (!DefinitionStatus.DESIGNING.getCode().equals(existing.getStatus())
                 && !DefinitionStatus.HISTORICAL.getCode().equals(existing.getStatus())) {
             throw new BusinessException("只有设计中或已停用的流程才能删除");
@@ -118,6 +124,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     @Override
     public ProcessDefinitionDTO getById(Long id) {
         ProcessDefinition po = this.processDefinitionMapper.selectById(id);
+        this.checkOwner(po);
         return ProcessDefinitionDTO.of(po);
     }
 
@@ -125,6 +132,8 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     public PageResult<ProcessDefinitionDTO> page(Integer pageNum, Integer pageSize, String processCode, String processName, Integer status) {
         Page<ProcessDefinition> page = new Page<>(pageNum, pageSize);
         LambdaQueryWrapper<ProcessDefinition> wrapper = new LambdaQueryWrapper<>();
+        // 流程定义按创建人(商户账号)隔离,只能看到自己创建的流程
+        wrapper.eq(ProcessDefinition::getCreateBy, SecurityUtils.getUserId());
         if (processCode != null && !processCode.isEmpty()) {
             wrapper.like(ProcessDefinition::getProcessCode, processCode);
         }
@@ -147,8 +156,9 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
     public void publish(Long id) {
         ProcessDefinition definition = this.processDefinitionMapper.selectById(id);
         this.validateDesigning(definition);
-        // 查询并停用当前启用的同编码流程
-        this.disableOldVersion(definition.getProcessCode());
+        this.checkOwner(definition);
+        // 查询并停用当前创建人的同编码流程
+        this.disableOldVersion(definition.getProcessCode(), definition.getCreateBy());
         this.enableDefinition(id);
     }
 
@@ -159,6 +169,7 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         if (definition == null) {
             throw new BusinessException("流程定义不存在");
         }
+        this.checkOwner(definition);
         if (!DefinitionStatus.ENABLED.getCode().equals(definition.getStatus())) {
             throw new BusinessException("只有启用中的流程才能停用");
         }
@@ -175,16 +186,17 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         if (definition == null) {
             throw new BusinessException("流程定义不存在");
         }
+        this.checkOwner(definition);
         if (!DefinitionStatus.HISTORICAL.getCode().equals(definition.getStatus())) {
             throw new BusinessException("只有已停用的流程才能启用");
         }
-        this.disableOldVersion(definition.getProcessCode());
+        this.disableOldVersion(definition.getProcessCode(), definition.getCreateBy());
         this.enableDefinition(id);
     }
 
     @Override
     public List<ProcessDefinitionDTO> listEnabled() {
-        List<ProcessDefinition> list = this.processDefinitionMapper.selectEnabledList();
+        List<ProcessDefinition> list = this.processDefinitionMapper.selectEnabledList(SecurityUtils.getUserId());
         return list.stream()
                 .map(ProcessDefinitionDTO::of)
                 .collect(Collectors.toList());
@@ -199,14 +211,28 @@ public class ProcessDefinitionServiceImpl implements ProcessDefinitionService {
         }
     }
 
-    private void disableOldVersion(String processCode) {
+    private void disableOldVersion(String processCode, Long ownerId) {
         LambdaUpdateWrapper<ProcessDefinition> wrapper = new LambdaUpdateWrapper<>();
         wrapper.eq(ProcessDefinition::getProcessCode, processCode)
+                .eq(ProcessDefinition::getCreateBy, ownerId)
                 .eq(ProcessDefinition::getStatus, DefinitionStatus.ENABLED.getCode())
                 .set(ProcessDefinition::getStatus, DefinitionStatus.HISTORICAL.getCode());
         this.processDefinitionMapper.update(null, wrapper);
     }
 
+    /**
+     * 流程定义按创建人隔离:只能操作自己(本商户账号)创建的流程。
+     * <p>创建人取自流程发起时的登录账号,商户通过 iframe 使用各自的 qqflow 账号。</p>
+     */
+    private void checkOwner(ProcessDefinition definition) {
+        if (definition == null) {
+            return;
+        }
+        if (!Objects.equals(definition.getCreateBy(), SecurityUtils.getUserId())) {
+            throw new BusinessException("无权操作该流程");
+        }
+    }
+
     private void enableDefinition(Long id) {
         LambdaUpdateWrapper<ProcessDefinition> wrapper = new LambdaUpdateWrapper<>();
         wrapper.eq(ProcessDefinition::getId, id)

+ 10 - 1
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java

@@ -408,7 +408,7 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
         SysUser user = this.sysUserMapper.selectById(userId);
         Long deptId = user != null ? user.getDeptId() : null;
         String instanceNo = dto.getInstanceNo() != null ? dto.getInstanceNo() : this.generateInstanceNo();
-        return this.processInstanceAssembler.buildNew(
+        ProcessInstance instance = this.processInstanceAssembler.buildNew(
                 instanceNo,
                 definition.getId(),
                 definition.getVersion(),
@@ -419,6 +419,15 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
                 dto.getAttachmentUrls(),
                 ProcessStatus.PENDING_RECEIVE.getCode()
         );
+        // 外部系统指定的节点审批人持久化到实例,后续节点推进时仍可读取
+        if (dto.getNodeAssignees() != null && !dto.getNodeAssignees().isEmpty()) {
+            try {
+                instance.setNodeAssignees(this.objectMapper.writeValueAsString(dto.getNodeAssignees()));
+            } catch (Exception e) {
+                throw new BusinessException("节点审批人参数格式错误");
+            }
+        }
+        return instance;
     }
 
     private String generateInstanceNo() {

+ 219 - 8
src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java

@@ -1,7 +1,12 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.qqflow.engine.domain.ewechat.service.EnterpriseWechatMsgService;
+import com.qqflow.engine.domain.flow.enums.ApprovalAction;
+import com.qqflow.engine.domain.flow.mapper.ProcessInstanceMapper;
 import com.qqflow.engine.domain.flow.po.ApprovalTask;
+import com.qqflow.engine.domain.flow.po.ProcessInstance;
 import com.qqflow.engine.domain.flow.service.NotificationService;
 import com.qqflow.engine.domain.system.dto.WeComConfigDTO;
 import com.qqflow.engine.domain.system.entity.SysUser;
@@ -11,8 +16,12 @@ import com.qqflow.engine.domain.system.mapper.SysUserRoleMapper;
 import com.qqflow.engine.domain.system.service.SysNotificationConfigService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
@@ -30,27 +39,184 @@ public class WeComNotificationService implements NotificationService {
 
     private static final String ASSIGNEE_TYPE_ROLE = "ROLE";
 
+    /** textcard 标题最多 128 字节(中文约 42 字),截断到 40 字留余量 */
+    private static final int TEXT_CARD_TITLE_MAX = 40;
+
     private final SysUserMapper sysUserMapper;
     private final SysUserRoleMapper sysUserRoleMapper;
     private final SysNotificationConfigService sysNotificationConfigService;
     private final EnterpriseWechatMsgService wechatMsgService;
+    private final ProcessInstanceMapper processInstanceMapper;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * H5 前端地址(逗号分隔:第一个为实际跳转地址,其余为免登 redirect 白名单);
+     * 未配置时降级为纯文本通知
+     */
+    @Value("${wecom.h5-base-url:}")
+    private String h5BaseUrl;
 
     @Override
-    public void notifyTaskAssigned(List<Long> assigneeIds, String assigneeType, String processName, String instanceTitle, String nodeName) {
+    public void notifyTaskAssigned(Long instanceId, List<Long> assigneeIds, String assigneeType,
+                                   String processName, String instanceTitle, String nodeName) {
         if (isSuppressed() || !this.isEnabled()) return;
+        List<String> wecomUserIds = new ArrayList<>();
         for (Long assigneeId : assigneeIds) {
             String wecomUserId = resolveWeComUserId(assigneeId, assigneeType);
-            if (wecomUserId == null) continue;
-            String content = String.format("【待办提醒】\n流程:%s\n标题:%s\n节点:%s\n请及时处理。",
-                    processName, instanceTitle, nodeName);
-            wechatMsgService.sendTextToUsers(Collections.singletonList(wecomUserId), content);
+            if (wecomUserId != null) wecomUserIds.add(wecomUserId);
+        }
+        if (wecomUserIds.isEmpty()) return;
+        String title = buildTaskTitle(instanceId, instanceTitle);
+        String description = buildTaskDescription(instanceId, processName, instanceTitle, nodeName);
+        String url = h5Url();
+        if (url != null) {
+            // 文本卡片:券面摘要 + 可点击跳转企微 H5 待办(点击后免登)
+            boolean cardSent = wechatMsgService.sendTextCardToUsers(
+                    wecomUserIds, title, description, url + "/m/todo", "去审批");
+            if (!cardSent) {
+                // 卡片发送失败时降级纯文本,至少保证审批人收到待办提醒
+                wechatMsgService.sendTextToUsers(wecomUserIds, "【待办提醒】\n" + toPlainText(description));
+            }
+        } else {
+            wechatMsgService.sendTextToUsers(wecomUserIds, "【待办提醒】\n" + toPlainText(description));
+        }
+    }
+
+    /**
+     * 待办卡片标题:券审批用「【优惠券审批】券名待您审批」,其它流程用「【待办审批】实例标题」
+     */
+    private String buildTaskTitle(Long instanceId, String instanceTitle) {
+        JsonNode task = loadScrmTask(instanceId);
+        if (task != null && task.path("taskType").asInt() == 10) {
+            String couponName = task.path("taskName").asText(instanceTitle);
+            return textCardTitle("【优惠券审批】", couponName + "待您审批");
+        }
+        if (task != null && task.path("taskType").asInt() == 20) {
+            String taskName = task.path("taskName").asText(instanceTitle);
+            return textCardTitle("【任务审批】", taskName + "待您审批");
+        }
+        return textCardTitle("【待办审批】", instanceTitle);
+    }
+
+    /**
+     * 待办卡片描述:券审批从 formData.scrmTask 取券名/力度/数量/推送方;
+     * 其它流程保持通用的流程/标题/节点描述。
+     */
+    private String buildTaskDescription(Long instanceId, String processName, String instanceTitle, String nodeName) {
+        JsonNode task = loadScrmTask(instanceId);
+        if (task != null && task.path("taskType").asInt() == 10) {
+            String couponName = task.path("taskName").asText(instanceTitle);
+            String face = findItemValue(task, "发行信息", "面额/折扣");
+            String count = findItemValue(task, "发行信息", "发行数量");
+            String submitter = task.path("submitUserName").asText("");
+            StringBuilder sb = new StringBuilder();
+            sb.append("<div class=\"gray\">券名称:").append(couponName).append("</div>");
+            if (StringUtils.hasText(face)) {
+                sb.append("<div class=\"gray\">优惠力度:").append(face).append("</div>");
+            }
+            if (StringUtils.hasText(count)) {
+                sb.append("<div class=\"gray\">发行数量:").append(count).append("</div>");
+            }
+            if (StringUtils.hasText(submitter)) {
+                sb.append("<div class=\"gray\">推送方:").append(submitter).append("</div>");
+            }
+            sb.append("<div class=\"gray\">当前节点:").append(nodeName).append("</div>");
+            return sb.toString();
+        }
+        if (task != null && task.path("taskType").asInt() == 20) {
+            String taskName = task.path("taskName").asText(instanceTitle);
+            String taskTypeName = findItemValue(task, "任务信息", "任务类型");
+            String mainSceneName = findItemValue(task, "任务信息", "主场景");
+            // 新快照 label 为「子场景」,存量快照仍为「场景」
+            String sceneName = findItemValue(task, "任务信息", "子场景");
+            if (!StringUtils.hasText(sceneName)) {
+                sceneName = findItemValue(task, "任务信息", "场景");
+            }
+            String sendTime = findItemValue(task, "发送设置", "发送时间");
+            if (!StringUtils.hasText(sendTime)) {
+                sendTime = findItemValue(task, "触发设置", "生效时间");
+            }
+            String submitter = task.path("submitUserName").asText("");
+            StringBuilder sb = new StringBuilder();
+            sb.append("<div class=\"gray\">任务名称:").append(taskName).append("</div>");
+            if (StringUtils.hasText(taskTypeName)) {
+                sb.append("<div class=\"gray\">任务类型:").append(taskTypeName).append("</div>");
+            }
+            if (StringUtils.hasText(mainSceneName)) {
+                sb.append("<div class=\"gray\">主场景:").append(mainSceneName).append("</div>");
+            }
+            if (StringUtils.hasText(sceneName)) {
+                sb.append("<div class=\"gray\">场景:").append(sceneName).append("</div>");
+            }
+            if (StringUtils.hasText(sendTime)) {
+                sb.append("<div class=\"gray\">发送时间:").append(sendTime).append("</div>");
+            }
+            if (StringUtils.hasText(submitter)) {
+                sb.append("<div class=\"gray\">推送方:").append(submitter).append("</div>");
+            }
+            sb.append("<div class=\"gray\">当前节点:").append(nodeName).append("</div>");
+            return sb.toString();
+        }
+        return String.format("流程:%s\n标题:%s\n节点:%s\n请及时处理。", processName, instanceTitle, nodeName);
+    }
+
+    private JsonNode loadScrmTask(Long instanceId) {
+        if (instanceId == null) {
+            return null;
+        }
+        ProcessInstance instance = processInstanceMapper.selectById(instanceId);
+        if (instance == null || !StringUtils.hasText(instance.getFormData())) {
+            return null;
         }
+        try {
+            JsonNode task = objectMapper.readTree(instance.getFormData()).path("scrmTask");
+            return task.isMissingNode() ? null : task;
+        } catch (Exception e) {
+            log.warn("[企微消息] 解析表单快照失败 instanceId={}", instanceId, e);
+            return null;
+        }
+    }
+
+    private String findItemValue(JsonNode task, String groupTitle, String label) {
+        for (JsonNode group : task.path("groups")) {
+            if (!groupTitle.equals(group.path("title").asText())) {
+                continue;
+            }
+            for (JsonNode item : group.path("items")) {
+                if (label.equals(item.path("label").asText())) {
+                    return item.path("value").asText();
+                }
+            }
+        }
+        return null;
+    }
+
+    /** textcard 的 HTML 描述降级为纯文本(未配置 H5 时使用) */
+    private String toPlainText(String description) {
+        return description.replace("<div class=\"gray\">", "").replace("</div>", "\n").trim();
     }
 
     @Override
     public void notifyTaskCompleted(Long instanceId, String processName, String instanceTitle,
                                     String nodeName, String operatorName, String action) {
-        // 审批完成通知:目前仅记录日志,后续可扩展
+        if (isSuppressed() || !this.isEnabled()) return;
+        // 通知流程发起人:其发起的流程处理到了哪个节点、结果如何
+        ProcessInstance instance = processInstanceMapper.selectById(instanceId);
+        if (instance == null || instance.getApplicantId() == null) return;
+        SysUser applicant = sysUserMapper.selectById(instance.getApplicantId());
+        if (applicant == null || !StringUtils.hasText(applicant.getWecomUserId())) return;
+        String actionText = resolveActionText(action);
+        // 事件里的 processName 可能为空,避免出现「流程:」空行
+        String processLine = StringUtils.hasText(processName) ? "流程:" + processName + "\n" : "";
+        String description = processLine + String.format("标题:%s\n节点「%s」已由 %s 处理(%s)",
+                instanceTitle, nodeName, operatorName, actionText);
+        String url = h5Url();
+        if (url != null) {
+            wechatMsgService.sendTextCardToUsers(Collections.singletonList(applicant.getWecomUserId()),
+                    textCardTitle("【审批进度】", instanceTitle), description, url + "/m/todo", "查看");
+        } else {
+            wechatMsgService.sendTextToUsers(Collections.singletonList(applicant.getWecomUserId()), description);
+        }
     }
 
     @Override
@@ -68,8 +234,17 @@ public class WeComNotificationService implements NotificationService {
     public void notifyTaskOverdue(ApprovalTask task, String processName, String instanceTitle, String nodeName, boolean overdue) {
         if (isSuppressed() || !this.isEnabled()) return;
         String type = overdue ? "已超时催办" : "即将超时提醒";
-        String content = String.format("【%s】\n流程:%s\n标题:%s\n节点:%s\n请及时处理。",
-                type, processName, instanceTitle, nodeName);
+        String description = String.format("流程:%s\n标题:%s\n节点:%s\n请及时处理。", processName, instanceTitle, nodeName);
+        String url = h5Url();
+        if (url != null) {
+            List<String> wecomUserIds = ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(task.getAssigneeType())
+                    ? wechatMsgService.getRoleWecomUserIds(task.getAssigneeId())
+                    : resolveUserWecomUserIds(task.getAssigneeId());
+            wechatMsgService.sendTextCardToUsers(wecomUserIds, textCardTitle("【" + type + "】", instanceTitle),
+                    description, url + "/m/todo", "去审批");
+            return;
+        }
+        String content = "【" + type + "】\n" + description;
         if (ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(task.getAssigneeType())) {
             wechatMsgService.sendTextToRole(task.getAssigneeId(), content);
         } else {
@@ -77,6 +252,42 @@ public class WeComNotificationService implements NotificationService {
         }
     }
 
+    /** 单人任务的企微 userid 列表(未绑定企微返回空列表) */
+    private List<String> resolveUserWecomUserIds(Long userId) {
+        SysUser user = sysUserMapper.selectById(userId);
+        if (user == null || !StringUtils.hasText(user.getWecomUserId())) {
+            return Collections.emptyList();
+        }
+        return Collections.singletonList(user.getWecomUserId());
+    }
+
+    /** 实际跳转用的 H5 地址(配置逗号分隔时取第一个);未配置返回 null → 降级纯文本 */
+    private String h5Url() {
+        if (!StringUtils.hasText(h5BaseUrl)) return null;
+        return Arrays.stream(h5BaseUrl.split(","))
+                .map(String::trim)
+                .filter(StringUtils::hasText)
+                .findFirst()
+                .map(url -> url.endsWith("/") ? url.substring(0, url.length() - 1) : url)
+                .orElse(null);
+    }
+
+    /** textcard 标题:前缀 + 实例标题,超长截断(企微限制 128 字节) */
+    private String textCardTitle(String prefix, String instanceTitle) {
+        String title = prefix + (instanceTitle == null ? "" : instanceTitle);
+        return title.length() > TEXT_CARD_TITLE_MAX ? title.substring(0, TEXT_CARD_TITLE_MAX) + "…" : title;
+    }
+
+    /** 审批动作 → 中文(用于进度通知) */
+    private String resolveActionText(String action) {
+        if (action == null) return "";
+        if (ApprovalAction.APPROVE.getCode().equalsIgnoreCase(action)) return ApprovalAction.APPROVE.getDesc();
+        if (ApprovalAction.REJECT.getCode().equalsIgnoreCase(action)) return ApprovalAction.REJECT.getDesc();
+        if (ApprovalAction.RETURN.getCode().equalsIgnoreCase(action)) return ApprovalAction.RETURN.getDesc();
+        if (ApprovalAction.TRANSFER.getCode().equalsIgnoreCase(action)) return ApprovalAction.TRANSFER.getDesc();
+        return action;
+    }
+
     private String resolveWeComUserId(Long assigneeId, String assigneeType) {
         // 企微通知按用户维度发送:角色任务取该角色下第一个有企微账号的用户
         if (ASSIGNEE_TYPE_ROLE.equalsIgnoreCase(assigneeType)) {

+ 7 - 0
src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java

@@ -58,6 +58,13 @@ public class SysUserController {
         return Result.ok(UserAssembler.toDTO(user));
     }
 
+    @Operation(summary = "根据企业微信 userid 查询用户(外部系统审批人映射用)")
+    @GetMapping("/wecom/{wecomUserId}")
+    public Result<UserDTO> getByWecomUserId(@PathVariable String wecomUserId) {
+        SysUser user = sysUserService.getByWecomUserId(wecomUserId);
+        return Result.ok(UserAssembler.toDTO(user));
+    }
+
     @Operation(summary = "新增用户")
     @PostMapping
     @PreAuthorize("hasAnyRole('super_admin','dept_manager')")

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

@@ -24,5 +24,13 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
         );
     }
 
+    /** 按企微 userid 查用户(免登 OAuth 用) */
+    default SysUser selectByWecomUserId(String wecomUserId) {
+        return this.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
+                        .eq(SysUser::getWecomUserId, wecomUserId)
+        );
+    }
+
     List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
 }

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

@@ -22,6 +22,11 @@ public interface SysUserService extends IService<SysUser> {
 
     SysUser getByUsername(String username);
 
+    /**
+     * 按企业微信 userid 查用户(wl-scrm 审批人映射用)。
+     */
+    SysUser getByWecomUserId(String wecomUserId);
+
     List<String> loadUserRoles(Long userId);
 
     String login(LoginDTO loginDTO);

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

@@ -342,6 +342,14 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     }
 
     @Override
+    public SysUser getByWecomUserId(String wecomUserId) {
+        if (wecomUserId == null || wecomUserId.isBlank()) {
+            return null;
+        }
+        return this.baseMapper.selectByWecomUserId(wecomUserId);
+    }
+
+    @Override
     public List<String> loadUserRoles(Long userId) {
         return this.baseMapper.selectRoleCodesByUserId(userId);
     }

+ 7 - 2
src/main/resources/application-test.yml

@@ -1,6 +1,6 @@
 spring:
   datasource:
-    url: jdbc:mysql://192.168.110.4:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai
+    url: jdbc:mysql://192.168.110.4:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai&forceConnectionTimeZoneToSession=true
     driver-class-name: com.mysql.cj.jdbc.Driver
     username: qqflowengine
     password: 123456
@@ -45,4 +45,9 @@ enterprise-wechat:
   suite-id: dk0580416972e402b8
   suite-secret: 3aV48BTZHxDBeltZgdiRAUhttqsE5RxNs5pzElGhHHg
   agent-id: 1000095
-  parent-id: 0
+  parent-id: 0
+
+# 企微 H5 审批端(textcard 跳转地址 + 企微 OAuth redirect 白名单)
+wecom:
+  # 必须与企微应用「可信域名/网页授权域名」一致;未配置时审批消息会降级为纯文本
+  h5-base-url: ${WECOM_H5_BASE_URL:http://test.wefanbot.com:13980}

+ 7 - 0
src/main/resources/application.yml

@@ -42,4 +42,11 @@ jwt:
   header: Authorization
   prefix: Bearer
 
+# 企微 H5 审批端
+wecom:
+  # H5 前端地址(逗号分隔:第一个为审批消息 textcard 的实际跳转地址,其余为免登 redirect 白名单)
+  # 例:https://h5.qqflow.example.com,https://h5-test.qqflow.example.com
+  # 未配置时:审批消息降级为纯文本;免登接口不可用(提示配置缺失)
+  h5-base-url: ${WECOM_H5_BASE_URL:}
+
 

+ 5 - 3
src/main/resources/mapper/flow/ApprovalTaskMapper.xml

@@ -66,11 +66,12 @@
         <if test="currentNodeId != null and currentNodeId != ''">
             AND pi.current_node_id = #{currentNodeId}
         </if>
-        ORDER BY t.create_time ASC
+        ORDER BY t.create_time DESC, t.id DESC
     </select>
 
     <select id="selectHandledList" resultMap="BaseResultMap">
-        SELECT DISTINCT t.*, pi.title as instance_title, pi.instance_no as instance_no, pd.process_name as process_name
+        SELECT DISTINCT t.*, pi.title as instance_title, pi.instance_no as instance_no, pi.form_data as form_data,
+               pd.process_name as process_name, pd.form_schema as form_schema
         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
@@ -85,7 +86,8 @@
     </select>
 
     <select id="selectCcList" resultMap="BaseResultMap">
-        SELECT t.*, pi.title as instance_title, pi.instance_no as instance_no, pd.process_name as process_name
+        SELECT t.*, pi.title as instance_title, pi.instance_no as instance_no, pi.form_data as form_data,
+               pd.process_name as process_name, pd.form_schema as form_schema
         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

+ 2 - 0
src/main/resources/mapper/flow/ProcessInstanceMapper.xml

@@ -11,6 +11,8 @@
         <result column="applicant_id" property="applicantId"/>
         <result column="applicant_dept_id" property="applicantDeptId"/>
         <result column="form_data" property="formData"/>
+        <result column="attachment_urls" property="attachmentUrls"/>
+        <result column="node_assignees" property="nodeAssignees"/>
         <result column="current_node_id" property="currentNodeId"/>
         <result column="status" property="status"/>
         <result column="result" property="result"/>

+ 1 - 0
src/main/resources/schema-dev.sql

@@ -79,6 +79,7 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     applicant_dept_id BIGINT,
     form_data TEXT,
     attachment_urls TEXT,
+    node_assignees TEXT,
     current_node_id VARCHAR(50),
     status TINYINT DEFAULT 0 COMMENT '0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
     result VARCHAR(20),

+ 1 - 0
src/main/resources/schema-mysql.sql

@@ -102,6 +102,7 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     applicant_dept_id BIGINT COMMENT '申请人部门ID',
     form_data TEXT COMMENT '表单数据JSON',
     attachment_urls TEXT COMMENT '附件URL列表JSON',
+    node_assignees TEXT COMMENT '外部指定的节点审批人JSON(nodeKey -> 用户ID数组)',
     current_node_id VARCHAR(50) COMMENT '当前节点ID',
     status TINYINT DEFAULT 0 COMMENT '实例状态:0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
     result VARCHAR(20) COMMENT '审批结果:PASS/REJECT/RETURN/TRANSFER',

+ 1 - 0
src/main/resources/schema-test.sql

@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS bpm_process_instance (
     applicant_dept_id BIGINT,
     form_data TEXT,
     attachment_urls TEXT,
+    node_assignees TEXT,
     current_node_id VARCHAR(50),
     status TINYINT DEFAULT 0 COMMENT '0待接收 1待处理 2已通过 3已拒绝 4已回退 5整体完成 6已撤回 7已终止',
     result VARCHAR(20),