Pārlūkot izejas kodu

feat: 审批流支持“由发起方”进行审批

wuwenyi 4 dienas atpakaļ
vecāks
revīzija
830a6985bb
22 mainītis faili ar 621 papildinājumiem un 14 dzēšanām
  1. 2 0
      src/main/java/com/qqflow/engine/common/constant/SecurityConstants.java
  2. 12 0
      src/main/java/com/qqflow/engine/domain/ewechat/api/EnterpriseWechatApi.java
  3. 63 0
      src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java
  4. 80 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/req/EnterpriseWechatSendTextCardMsgReq.java
  5. 26 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatUserInfoRsp.java
  6. 24 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/WecomOAuthResultRsp.java
  7. 28 4
      src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java
  8. 170 0
      src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java
  9. 10 0
      src/main/java/com/qqflow/engine/domain/flow/dto/StartProcessDTO.java
  10. 8 0
      src/main/java/com/qqflow/engine/domain/flow/po/ProcessInstance.java
  11. 53 2
      src/main/java/com/qqflow/engine/domain/flow/service/impl/FlowEngineServiceImpl.java
  12. 10 1
      src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java
  13. 95 7
      src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java
  14. 7 0
      src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java
  15. 8 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java
  16. 5 0
      src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java
  17. 8 0
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java
  18. 7 0
      src/main/resources/application.yml
  19. 2 0
      src/main/resources/mapper/flow/ProcessInstanceMapper.xml
  20. 1 0
      src/main/resources/schema-dev.sql
  21. 1 0
      src/main/resources/schema-mysql.sql
  22. 1 0
      src/main/resources/schema-test.sql

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

+ 28 - 4
src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java

@@ -5,6 +5,7 @@ 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.po.EnterpriseWechatCorp;
 import com.qqflow.engine.domain.system.entity.SysUser;
@@ -57,6 +58,24 @@ public class EnterpriseWechatMsgService {
         }
     }
 
+    /** 给指定企微用户ID列表发送文本卡片消息(可点击跳转,用于审批待办/催办) */
+    public void sendTextCardToUsers(List<String> wecomUserIds, String title, String description, String url, String btnText) {
+        if (wecomUserIds == null || wecomUserIds.isEmpty()) return;
+        String accessToken = this.getCorpAccessToken();
+        if (accessToken == null) {
+            log.warn("[企微消息] 无法获取 access_token");
+            return;
+        }
+        try {
+            EnterpriseWechatSendTextCardMsgReq req = EnterpriseWechatSendTextCardMsgReq.toUsers(
+                    wecomUserIds, wechatProperties.getAgentId(), title, description, url, btnText);
+            enterpriseWechatApi.sendTextCardMsg(accessToken, req);
+            log.info("[企微消息] 已发送文本卡片 | 接收人数:{} | {}", wecomUserIds.size(), title);
+        } catch (Exception e) {
+            log.error("[企微消息] 文本卡片发送失败", e);
+        }
+    }
+
     /** 给指定角色下的所有企微用户发送文本消息 */
     public void sendTextToRole(Long roleId, String content) {
         List<String> wecomUserIds = this.getRoleWecomUserIds(roleId);
@@ -83,15 +102,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 地址不在白名单内");
+        }
+    }
+}

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

+ 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;

+ 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 表示仍需等待其他子节点。
      */

+ 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() {

+ 95 - 7
src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java

@@ -1,7 +1,10 @@
 package com.qqflow.engine.domain.flow.service.impl;
 
 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 +14,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 +37,63 @@ 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;
+
+    /**
+     * 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) {
         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 description = String.format("流程:%s\n标题:%s\n节点:%s\n请及时处理。", processName, instanceTitle, nodeName);
+        String url = h5Url();
+        if (url != null) {
+            // 文本卡片:可点击跳转企微 H5 待办(点击后免登)
+            wechatMsgService.sendTextCardToUsers(wecomUserIds, textCardTitle("【待办审批】", instanceTitle),
+                    description, url + "/m/todo", "去审批");
+        } else {
+            wechatMsgService.sendTextToUsers(wecomUserIds, "【待办提醒】\n" + description);
         }
     }
 
     @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 +111,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 +129,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 - 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:}
+
 

+ 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),