Przeglądaj źródła

feat: 企微通知/免登支持多企业路由(corp_id + agent_id 维度)

- enterprise_wechat_corp.agent_id、sys_user.corp_id(空回退默认授权企业)
- 消息发送按 wecom_user_id→corp_id 分组,每组用本企业 token+agentid,卡片链接带 corpId
- 授权回调捕获 auth_info.agent 写入 agentid,reset 同步刷新
- H5 免登按 state 中的 corpId 取 appid/agentid/token,用户按企业精确匹配
- 新增 sql/20261030_qqflow_multi_corp_routing.sql(DDL+回填+校验)

Co-Authored-By: Claude <noreply@anthropic.com>
wuwenyi 2 tygodni temu
rodzic
commit
754aa9fd9d
19 zmienionych plików z 355 dodań i 50 usunięć
  1. 83 0
      sql/20261030_qqflow_multi_corp_routing.sql
  2. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatAssembler.java
  3. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatEventHandler.java
  4. 3 2
      src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java
  5. 7 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.java
  6. 3 2
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.xml
  7. 18 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatPermanentCodeRsp.java
  8. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/po/EnterpriseWechatCorp.java
  9. 89 27
      src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java
  10. 77 12
      src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java
  11. 4 3
      src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java
  12. 3 0
      src/main/java/com/qqflow/engine/domain/system/dto/BindWeComDTO.java
  13. 3 0
      src/main/java/com/qqflow/engine/domain/system/entity/SysUser.java
  14. 17 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java
  15. 5 2
      src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java
  16. 9 2
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java
  17. 1 0
      src/main/resources/schema-dev.sql
  18. 16 0
      src/main/resources/schema-mysql.sql
  19. 2 0
      src/main/resources/schema-test.sql

+ 83 - 0
sql/20261030_qqflow_multi_corp_routing.sql

@@ -0,0 +1,83 @@
+-- =====================================================================
+-- qqflow 企微通知/免登多企业支持:企业 agent_id 与用户 corp_id 维度
+--
+-- 执行库:qqflow 审批引擎库(qqflowengine)
+--
+-- 背景:每个商户是独立企微企业。qqflow 发通知/免登固定走第一条授权企业
+--       (五菱汽车)的 token + 全局 agent-id,导致其他商户的审批人
+--       收不到通知(81013 user & party & tag all invalid)、点进 H5 免登失败。
+--       本次为「企业」补 agent_id(代开发各企业应用 agentid 不同)、
+--       为「用户」补 corp_id(归属企业,发送时按企业路由 token + agentid)。
+--
+-- 说明:
+--   · 企业表复用现有 enterprise_wechat_corp(授权回调自动写行),不新建;
+--   · sys_user.corp_id 为空 → 回退默认授权企业(第一条 = 五菱汽车),
+--     存量数据零迁移即可保持原行为;
+--   · 新企业授权回调(create_auth)会在代码发布后自动写入 agent_id;
+--   · 脚本幂等:条件式加列 + 条件式回填,可重复执行。
+-- =====================================================================
+
+-- 执行前确认当前库:必须是 qqflowengine。
+SELECT DATABASE() AS current_database;
+
+-- -------------------------------------------------------------------
+-- 1) enterprise_wechat_corp 增加 agent_id(代开发应用在该企业内的 agentid)
+-- -------------------------------------------------------------------
+SET @hasCorpAgentId = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'enterprise_wechat_corp'
+      AND COLUMN_NAME = 'agent_id');
+SET @ddlCorpAgentId = IF(@hasCorpAgentId = 0,
+    'ALTER TABLE `enterprise_wechat_corp` ADD COLUMN `agent_id` INT NULL COMMENT ''代开发应用在该企业内的 agentid(各企业不同);为空回退全局 enterprice-wechat.agent-id'' AFTER `corp_name`',
+    'SELECT ''skip: agent_id 已存在'' AS upgrade_result');
+PREPARE stmt_corp_agent_id FROM @ddlCorpAgentId;
+EXECUTE stmt_corp_agent_id;
+DEALLOCATE PREPARE stmt_corp_agent_id;
+
+-- -------------------------------------------------------------------
+-- 2) sys_user 增加 corp_id(用户归属企微企业)
+-- -------------------------------------------------------------------
+SET @hasUserCorpId = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user'
+      AND COLUMN_NAME = 'corp_id');
+SET @ddlUserCorpId = IF(@hasUserCorpId = 0,
+    'ALTER TABLE `sys_user` ADD COLUMN `corp_id` VARCHAR(64) NULL COMMENT ''所属企微企业 corp_id;为空回退默认授权企业'' AFTER `wecom_user_id`',
+    'SELECT ''skip: corp_id 已存在'' AS upgrade_result');
+PREPARE stmt_user_corp_id FROM @ddlUserCorpId;
+EXECUTE stmt_user_corp_id;
+DEALLOCATE PREPARE stmt_user_corp_id;
+
+-- -------------------------------------------------------------------
+-- 3) 回填:存量授权企业补 agent_id(全局配置 1000136 即五菱汽车的 agentid)
+-- -------------------------------------------------------------------
+UPDATE enterprise_wechat_corp
+SET agent_id = 1000136
+WHERE corp_id = 'wpU17nDAAANKJiASPpuci_9pBEh2Nnow'
+  AND agent_id IS NULL;
+
+-- -------------------------------------------------------------------
+-- 4) 回填:存量 qqflow 用户的 corp_id
+--    数据来源:wl-scrm 库 qqflow_user_map JOIN wxcp_member
+--    (2026-10-30 生产实际数据,见校验语句核对)
+-- -------------------------------------------------------------------
+UPDATE sys_user
+SET corp_id = 'wpU17nDAAANKJiASPpuci_9pBEh2Nnow'   -- 五菱汽车
+WHERE id = 16
+  AND corp_id IS NULL;
+
+UPDATE sys_user
+SET corp_id = 'wpU17nDAAAdkW4OnezBiL6LVaPZ95NDQ'   -- 五菱新事业
+WHERE id = 20
+  AND corp_id IS NULL;
+
+-- =====================================================================
+-- 5) 校验
+-- =====================================================================
+-- 5.1 企业:应至少 1 行,五菱汽车行 agent_id=1000136
+SELECT id, corp_id, corp_name, agent_id, permanent_code IS NOT NULL AS has_permanent_code
+FROM enterprise_wechat_corp
+ORDER BY id;
+
+-- 5.2 用户:id=20(scrm_* 五菱新事业审批人)corp_id 应为 wpU17nDAAAdkW4OnezBiL6LVaPZ95NDQ
+SELECT id, username, real_name, wecom_user_id, corp_id
+FROM sys_user
+WHERE id IN (16, 20);

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

@@ -29,6 +29,11 @@ public class EnterpriseWechatAssembler extends CommonAssembler {
         corp.setCorpName(authCorpInfo.getCorpName());
         corp.setAuthUserId(authUserInfo.getUserid());
         corp.setAuthOpenUserId(authUserInfo.getOpenUserid());
+        // 代开发应用在各企业的 agentid 不同,取授权信息里第一个应用(本套件只有一个应用)
+        EnterpriseWechatPermanentCodeRsp.AuthInfo authInfo = rsp.getAuthInfo();
+        if (authInfo != null && authInfo.getAgent() != null && !authInfo.getAgent().isEmpty()) {
+            corp.setAgentId(authInfo.getAgent().get(0).getAgentid());
+        }
         if (Objects.nonNull(registerCodeInfo)) {
             corp.setRegisterCode(registerCodeInfo.getRegisterCode());
             corp.setTemplateId(registerCodeInfo.getTemplateId());

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

@@ -56,6 +56,9 @@ public class EnterpriseWechatEventHandler {
                 corpMapper.insert(assembler.assembleCorp(permanentCodeRsp));
             } else {
                 wechatCorp.setPermanentCode(permanentCodeRsp.getPermanentCode());
+                // 重新授权时 agentid 可能变化,同步刷新
+                EnterpriseWechatCorp fresh = assembler.assembleCorp(permanentCodeRsp);
+                wechatCorp.setAgentId(fresh.getAgentId());
                 corpMapper.updateById(wechatCorp);
             }
         } finally {
@@ -90,6 +93,8 @@ public class EnterpriseWechatEventHandler {
             if (wechatCorp != null) {
                 wechatCorp.setPermanentCode(permanentCodeRsp.getPermanentCode());
                 wechatCorp.setCorpName(permanentCodeRsp.getAuthCorpInfo().getCorpName());
+                // 永久授权码重置时 agentid 可能变化,同步刷新
+                wechatCorp.setAgentId(assembler.assembleCorp(permanentCodeRsp).getAgentId());
                 corpMapper.updateById(wechatCorp);
             }
         } finally {

+ 3 - 2
src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java

@@ -39,9 +39,10 @@ public class WecomOAuthController {
 
     @Operation(summary = "生成企微静默授权地址并 302 跳转")
     @GetMapping("/oauth-url")
-    public ResponseEntity<Void> oauthUrl(@RequestParam("redirect") String redirect) {
+    public ResponseEntity<Void> oauthUrl(@RequestParam("redirect") String redirect,
+                                         @RequestParam(value = "corpId", required = false) String corpId) {
         try {
-            String url = wecomOAuthService.buildAuthorizeUrl(redirect);
+            String url = wecomOAuthService.buildAuthorizeUrl(redirect, corpId);
             return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build();
         } catch (BusinessException e) {
             log.warn("[企微免登] 生成授权地址失败:{}", e.getMessage());

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

@@ -15,6 +15,13 @@ public interface EnterpriseWechatCorpMapper extends BaseMapper<EnterpriseWechatC
                         .last("LIMIT 1"));
     }
 
+    /** 按企业 corp_id 查授权企业(发送消息/免登按用户企业路由用) */
+    default EnterpriseWechatCorp selectByCorpId(String corpId) {
+        return this.selectOne(
+                Wrappers.<EnterpriseWechatCorp>lambdaQuery().eq(EnterpriseWechatCorp::getCorpId, corpId)
+                        .last("LIMIT 1"));
+    }
+
     default List<EnterpriseWechatCorp> selectAllCorps() {
         return this.selectList(Wrappers.emptyWrapper());
     }

+ 3 - 2
src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.xml

@@ -12,6 +12,7 @@
     <result column="suite_id" jdbcType="VARCHAR" property="suiteId" />
     <result column="corp_id" jdbcType="VARCHAR" property="corpId" />
     <result column="corp_name" jdbcType="VARCHAR" property="corpName" />
+    <result column="agent_id" jdbcType="INTEGER" property="agentId" />
     <result column="auth_user_id" jdbcType="VARCHAR" property="authUserId" />
     <result column="auth_open_user_id" jdbcType="VARCHAR" property="authOpenUserId" />
     <result column="register_code" jdbcType="VARCHAR" property="registerCode" />
@@ -20,8 +21,8 @@
   </resultMap>
   <sql id="Base_Column_List">
     <!--@mbg.generated-->
-    id, created_by, created_time, updated_by, updated_time, permanent_code, callback_code, 
-    suite_id, corp_id, corp_name, auth_user_id, auth_open_user_id, register_code, template_id, 
+    id, created_by, created_time, updated_by, updated_time, permanent_code, callback_code,
+    suite_id, corp_id, corp_name, agent_id, auth_user_id, auth_open_user_id, register_code, template_id,
     `state`
   </sql>
 </mapper>

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

@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming;
 import lombok.Getter;
 import lombok.Setter;
 
+import java.util.List;
+
 /**
  * @author WuWenYi
  * @since 2025/3/18
@@ -22,6 +24,22 @@ public class EnterpriseWechatPermanentCodeRsp extends EnterpriseWechatBaseRsp {
 
     private RegisterCodeInfo registerCodeInfo;
 
+    private AuthInfo authInfo;
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class AuthInfo{
+        private List<Agent> agent;
+    }
+
+    @Getter
+    @Setter
+    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+    public static class Agent{
+        private Integer agentid;
+        private String name;
+    }
 
     @Getter
     @Setter

+ 5 - 0
src/main/java/com/qqflow/engine/domain/ewechat/po/EnterpriseWechatCorp.java

@@ -33,6 +33,11 @@ public class EnterpriseWechatCorp extends BaseUpdateModel implements Serializabl
     private String corpName;
 
     /**
+     * 代开发应用在该企业内的 agentid(各企业不同)
+     */
+    private Integer agentId;
+
+    /**
      * 授权管理员id
      */
     private String authUserId;

+ 89 - 27
src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java

@@ -18,8 +18,12 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 
 /**
@@ -44,39 +48,93 @@ public class EnterpriseWechatMsgService {
     /** 给指定企微用户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 false;
-        }
-        try {
-            EnterpriseWechatSendTextMsgReq req = EnterpriseWechatSendTextMsgReq.toUsers(
-                    wecomUserIds, wechatProperties.getAgentId(), content);
-            EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextMsg(accessToken, req);
-            return logSendResult(rsp, "文本消息", wecomUserIds.size());
-        } catch (Exception e) {
-            log.error("[企微消息] 发送失败", e);
-            return false;
+        boolean allOk = true;
+        // 多企业部署:接收人按所属企业分组,各自用本企业的 token + agentid 发送
+        for (Map.Entry<String, List<String>> entry : groupUserIdsByCorp(wecomUserIds).entrySet()) {
+            EnterpriseWechatCorp corp = this.getCorpById(entry.getKey());
+            List<String> corpUserIds = entry.getValue();
+            String accessToken = this.getCorpAccessToken(corp);
+            if (accessToken == null) {
+                log.warn("[企微消息] 无法获取企业 {} 的 access_token,跳过 {} 人", entry.getKey(), corpUserIds.size());
+                allOk = false;
+                continue;
+            }
+            try {
+                EnterpriseWechatSendTextMsgReq req = EnterpriseWechatSendTextMsgReq.toUsers(
+                        corpUserIds, this.resolveAgentId(corp), content);
+                EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextMsg(accessToken, req);
+                allOk &= logSendResult(rsp, "文本消息", corpUserIds.size());
+            } catch (Exception e) {
+                log.error("[企微消息] 发送失败 corpId={}", entry.getKey(), e);
+                allOk = false;
+            }
         }
+        return allOk;
     }
 
     /** 给指定企微用户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;
+        boolean allOk = true;
+        // 多企业部署:接收人按所属企业分组发送,卡片链接携带 corpId 供 H5 免登按企业路由
+        for (Map.Entry<String, List<String>> entry : groupUserIdsByCorp(wecomUserIds).entrySet()) {
+            EnterpriseWechatCorp corp = this.getCorpById(entry.getKey());
+            List<String> corpUserIds = entry.getValue();
+            String accessToken = this.getCorpAccessToken(corp);
+            if (accessToken == null) {
+                log.warn("[企微消息] 无法获取企业 {} 的 access_token,跳过 {} 人", entry.getKey(), corpUserIds.size());
+                allOk = false;
+                continue;
+            }
+            try {
+                EnterpriseWechatSendTextCardMsgReq req = EnterpriseWechatSendTextCardMsgReq.toUsers(
+                        corpUserIds, this.resolveAgentId(corp), title, description,
+                        appendCorpIdToUrl(url, entry.getKey()), btnText);
+                EnterpriseWechatSendTextMsgRsp rsp = enterpriseWechatApi.sendTextCardMsg(accessToken, req);
+                allOk &= logSendResult(rsp, "文本卡片", corpUserIds.size());
+            } catch (Exception e) {
+                log.error("[企微消息] 文本卡片发送失败 corpId={}", entry.getKey(), e);
+                allOk = 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;
+        return allOk;
+    }
+
+    /** 接收人按所属企业分组:wecom_user_id → sys_user.corp_id;无记录/为空 → 默认授权企业 */
+    private Map<String, List<String>> groupUserIdsByCorp(List<String> wecomUserIds) {
+        List<String> distinct = wecomUserIds.stream().distinct().collect(Collectors.toList());
+        Map<String, String> userCorpMap = new HashMap<>();
+        sysUserMapper.selectBatchByWecomUserIds(distinct).forEach(u ->
+                userCorpMap.putIfAbsent(u.getWecomUserId(), u.getCorpId()));
+        EnterpriseWechatCorp defaultCorp = this.getAuthorizedCorp();
+        String defaultCorpId = defaultCorp == null ? null : defaultCorp.getCorpId();
+        Map<String, List<String>> grouped = new LinkedHashMap<>();
+        for (String userid : wecomUserIds) {
+            String corpId = userCorpMap.getOrDefault(userid, defaultCorpId);
+            grouped.computeIfAbsent(corpId, k -> new ArrayList<>()).add(userid);
         }
+        return grouped;
+    }
+
+    /** 按 corp_id 查企业;为空回退默认授权企业 */
+    public EnterpriseWechatCorp getCorpById(String corpId) {
+        if (corpId == null || corpId.isBlank()) {
+            return this.getAuthorizedCorp();
+        }
+        return wechatCorpMapper.selectByCorpId(corpId);
+    }
+
+    /** 发送用的应用 agentid:优先企业自身(代开发各企业 agentid 不同),为空回退全局配置 */
+    public Integer resolveAgentId(EnterpriseWechatCorp corp) {
+        return corp != null && corp.getAgentId() != null ? corp.getAgentId() : wechatProperties.getAgentId();
+    }
+
+    /** 卡片链接追加 corpId 参数(H5 免登按企业取 appid/token 用) */
+    private String appendCorpIdToUrl(String url, String corpId) {
+        if (url == null || corpId == null || corpId.isBlank()) {
+            return url;
+        }
+        return url + (url.contains("?") ? "&" : "?") + "corpId=" + corpId;
     }
 
     /**
@@ -133,9 +191,13 @@ public class EnterpriseWechatMsgService {
         return corps.isEmpty() ? null : corps.get(0);
     }
 
-    /** 获取企业 access_token(Redis 缓存,TTL 7000s) */
+    /** 获取默认授权企业的 access_token(未绑定企业的用户/内部调用使用) */
     public String getCorpAccessToken() {
-        EnterpriseWechatCorp corp = this.getAuthorizedCorp();
+        return this.getCorpAccessToken(this.getAuthorizedCorp());
+    }
+
+    /** 获取指定企业的 access_token(Redis 按 corp_id 缓存,TTL 7000s) */
+    public String getCorpAccessToken(EnterpriseWechatCorp corp) {
         if (corp == null) return null;
 
         String cacheKey = ACCESS_TOKEN_CACHE_KEY + corp.getCorpId();

+ 77 - 12
src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java

@@ -1,5 +1,6 @@
 package com.qqflow.engine.domain.ewechat.service;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.common.util.JwtUtils;
 import com.qqflow.engine.common.util.RedisCache;
@@ -13,7 +14,9 @@ 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.Getter;
 import lombok.RequiredArgsConstructor;
+import lombok.Setter;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
@@ -33,7 +36,7 @@ import static com.qqflow.engine.common.constant.SecurityConstants.USER_TYPE_SYST
  *
  * <p>流程:
  * <ol>
- *   <li>H5 调 {@link #buildAuthorizeUrl(String)} 对应的接口 → 302 到企微 snsapi_base 静默授权;</li>
+ *   <li>H5 调 {@link #buildAuthorizeUrl(String, 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>
@@ -66,6 +69,7 @@ public class WecomOAuthService {
     private final SysUserService sysUserService;
     private final JwtUtils jwtUtils;
     private final RedisCache redisCache;
+    private final ObjectMapper objectMapper;
 
     /**
      * 允许的 H5 地址前缀(逗号分隔;第一个同时作为审批消息 textcard 的跳转地址)
@@ -73,23 +77,47 @@ public class WecomOAuthService {
     @Value("${wecom.h5-base-url:}")
     private String h5BaseUrlConfig;
 
+    /** state 中携带的内容:redirect + 用户所属企业(多企业免登按企业取 appid/token) */
+    @Getter
+    @Setter
+    static class OAuthState {
+        private String redirect;
+        private String corpId;
+
+        OAuthState() {
+        }
+
+        OAuthState(String redirect, String corpId) {
+            this.redirect = redirect;
+            this.corpId = corpId;
+        }
+    }
+
     /**
      * 生成企微静默授权跳转地址(带一次性 state)
+     *
+     * @param corpId 用户所属企业(可选;为空回退默认授权企业,兼容存量 H5 入口)
      */
-    public String buildAuthorizeUrl(String redirect) {
+    public String buildAuthorizeUrl(String redirect, String corpId) {
         validateRedirect(redirect);
-        EnterpriseWechatCorp corp = wechatMsgService.getAuthorizedCorp();
+        EnterpriseWechatCorp corp = wechatMsgService.getCorpById(corpId);
         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";
+        // state 携带 corpId:企微回跳只带 code+state,exchange 据此按企业取 token 换 userinfo
+        redisCache.setCacheObject(STATE_KEY_PREFIX + state, writeState(new OAuthState(redirect, corp.getCorpId())),
+                STATE_TTL_MINUTES, TimeUnit.MINUTES);
+        StringBuilder url = new StringBuilder(AUTHORIZE_URL)
+                .append("?appid=").append(corp.getCorpId())
+                .append("&redirect_uri=").append(URLEncoder.encode(redirect, StandardCharsets.UTF_8))
+                .append("&response_type=code&scope=snsapi_base")
+                .append("&state=").append(state);
+        Integer agentId = wechatMsgService.resolveAgentId(corp);
+        if (agentId != null) {
+            url.append("&agentid=").append(agentId);
+        }
+        return url.append("#wechat_redirect").toString();
     }
 
     /**
@@ -104,9 +132,16 @@ public class WecomOAuthService {
         if (!StringUtils.hasText(state) || redisCache.getCacheObject(stateKey) == null) {
             throw new BusinessException(40002, "授权状态已失效,请重新进入");
         }
+        String stateValue = redisCache.getCacheObject(stateKey);
         redisCache.deleteObject(stateKey);
 
-        String accessToken = wechatMsgService.getCorpAccessToken();
+        OAuthState oauthState = readState(stateValue);
+        // 按 state 里的企业取 access_token(多企业部署),未带 corpId 的存量 state 回退默认企业
+        EnterpriseWechatCorp corp = wechatMsgService.getCorpById(oauthState.getCorpId());
+        if (corp == null) {
+            throw new BusinessException("企业微信未授权,请联系管理员");
+        }
+        String accessToken = wechatMsgService.getCorpAccessToken(corp);
         if (accessToken == null) {
             throw new BusinessException("企业微信未授权,请联系管理员");
         }
@@ -114,7 +149,15 @@ public class WecomOAuthService {
         if (userInfo == null || !StringUtils.hasText(userInfo.getUserid())) {
             throw new BusinessException(40001, "该企微账号未绑定审批系统账号");
         }
-        SysUser user = sysUserMapper.selectByWecomUserId(userInfo.getUserid());
+        // 优先按企业精确匹配(多企业下 userid 可能重复),匹配不到回退全局匹配
+        // (兼容 corp_id 未回填的存量用户)
+        SysUser user = null;
+        if (StringUtils.hasText(oauthState.getCorpId())) {
+            user = sysUserMapper.selectByWecomUserIdAndCorpId(userInfo.getUserid(), oauthState.getCorpId());
+        }
+        if (user == null) {
+            user = sysUserMapper.selectByWecomUserId(userInfo.getUserid());
+        }
         if (user == null || (user.getStatus() != null && user.getStatus() != 0)) {
             throw new BusinessException(40001, "该企微账号未绑定审批系统账号");
         }
@@ -147,6 +190,28 @@ public class WecomOAuthService {
         return loginUser;
     }
 
+    /** state 序列化(JSON:redirect + corpId) */
+    private String writeState(OAuthState oauthState) {
+        try {
+            return objectMapper.writeValueAsString(oauthState);
+        } catch (Exception e) {
+            log.warn("[企微免登] state 序列化失败,降级为仅存 redirect", e);
+            return oauthState.getRedirect();
+        }
+    }
+
+    /** state 反序列化;解析失败按旧版兼容处理(旧 state 只存 redirect,无 corpId) */
+    private OAuthState readState(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return new OAuthState(null, null);
+        }
+        try {
+            return objectMapper.readValue(raw, OAuthState.class);
+        } catch (Exception e) {
+            return new OAuthState(raw, null);
+        }
+    }
+
     /**
      * redirect 必须命中配置的 H5 地址前缀(逗号分隔,支持多环境),防开放重定向
      */

+ 4 - 3
src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java

@@ -60,8 +60,9 @@ public class SysUserController {
 
     @Operation(summary = "根据企业微信 userid 查询用户(外部系统审批人映射用)")
     @GetMapping("/wecom/{wecomUserId}")
-    public Result<UserDTO> getByWecomUserId(@PathVariable String wecomUserId) {
-        SysUser user = sysUserService.getByWecomUserId(wecomUserId);
+    public Result<UserDTO> getByWecomUserId(@PathVariable String wecomUserId,
+                                            @RequestParam(required = false) String corpId) {
+        SysUser user = sysUserService.getByWecomUserId(wecomUserId, corpId);
         return Result.ok(UserAssembler.toDTO(user));
     }
 
@@ -106,7 +107,7 @@ public class SysUserController {
     @PutMapping("/{id}/wecom")
     @PreAuthorize("hasRole('super_admin')")
     public Result<Void> bindWeCom(@PathVariable Long id, @RequestBody BindWeComDTO dto) {
-        sysUserService.bindWeCom(id, dto.getWecomUserId(), dto.getWecomRemindEnabled());
+        sysUserService.bindWeCom(id, dto.getWecomUserId(), dto.getWecomRemindEnabled(), dto.getCorpId());
         return Result.ok();
     }
 }

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

@@ -13,6 +13,9 @@ public class BindWeComDTO {
     @Schema(description = "企业微信用户ID(空字符串或 null 表示解绑)")
     private String wecomUserId;
 
+    @Schema(description = "所属企微企业 corp_id(可选,为空回退默认授权企业)")
+    private String corpId;
+
     @Schema(description = "是否开启企微提醒:0-否 1-是")
     private Integer wecomRemindEnabled;
 }

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

@@ -46,6 +46,9 @@ public class SysUser {
     @Schema(description = "企业微信用户ID")
     private String wecomUserId;
 
+    @Schema(description = "所属企微企业 corp_id(为空回退默认授权企业)")
+    private String corpId;
+
     @Schema(description = "是否开启企微提醒:0-否 1-是")
     private Integer wecomRemindEnabled;
 

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

@@ -32,5 +32,22 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
         );
     }
 
+    /** 按企微 userid + 企业查用户(多企业下 userid 可能重复,外部系统同步时传 corpId 精确匹配) */
+    default SysUser selectByWecomUserIdAndCorpId(String wecomUserId, String corpId) {
+        return this.selectOne(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
+                        .eq(SysUser::getWecomUserId, wecomUserId)
+                        .eq(SysUser::getCorpId, corpId)
+        );
+    }
+
+    /** 批量按企微 userid 查用户(发送消息按企业分组用) */
+    default List<SysUser> selectBatchByWecomUserIds(List<String> wecomUserIds) {
+        return this.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
+                        .in(SysUser::getWecomUserId, wecomUserIds)
+        );
+    }
+
     List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
 }

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

@@ -24,8 +24,10 @@ public interface SysUserService extends IService<SysUser> {
 
     /**
      * 按企业微信 userid 查用户(wl-scrm 审批人映射用)。
+     *
+     * @param corpId 所属企微企业 corp_id(可选;多企业下 userid 可能重复,传了按企业精确匹配)
      */
-    SysUser getByWecomUserId(String wecomUserId);
+    SysUser getByWecomUserId(String wecomUserId, String corpId);
 
     List<String> loadUserRoles(Long userId);
 
@@ -46,6 +48,7 @@ public interface SysUserService extends IService<SysUser> {
      * @param userId            用户ID
      * @param wecomUserId       企业微信用户ID(空字符串或 null 表示解绑)
      * @param wecomRemindEnabled 是否开启企微提醒:0-否 1-是
+     * @param corpId            所属企微企业 corp_id(可选;为空不修改原值)
      */
-    void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled);
+    void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled, String corpId);
 }

+ 9 - 2
src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java

@@ -342,10 +342,13 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
     }
 
     @Override
-    public SysUser getByWecomUserId(String wecomUserId) {
+    public SysUser getByWecomUserId(String wecomUserId, String corpId) {
         if (wecomUserId == null || wecomUserId.isBlank()) {
             return null;
         }
+        if (corpId != null && !corpId.isBlank()) {
+            return this.baseMapper.selectByWecomUserIdAndCorpId(wecomUserId, corpId);
+        }
         return this.baseMapper.selectByWecomUserId(wecomUserId);
     }
 
@@ -419,7 +422,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled) {
+    public void bindWeCom(Long userId, String wecomUserId, Integer wecomRemindEnabled, String corpId) {
         if (userId == null) {
             throw new BusinessException("用户ID不能为空");
         }
@@ -431,6 +434,10 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
         update.setId(userId);
         update.setWecomUserId(wecomUserId);
         update.setWecomRemindEnabled(wecomRemindEnabled);
+        // 同步用户所属企业(为空不覆盖原值,保持默认企业回退行为)
+        if (corpId != null && !corpId.isBlank()) {
+            update.setCorpId(corpId);
+        }
         update.setUpdateTime(LocalDateTime.now());
         this.updateById(update);
     }

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

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS sys_user (
     employee_type VARCHAR(20) DEFAULT 'common_user',
     status TINYINT DEFAULT 0,
     wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    corp_id VARCHAR(64) COMMENT '所属企微企业 corp_id(为空回退默认授权企业)',
     wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

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

@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS sys_user (
     employee_type VARCHAR(20) DEFAULT 'common_user' COMMENT '员工类型:super_admin/flow_manager/dept_manager/common_user',
     status TINYINT DEFAULT 0 COMMENT '状态:0-正常,1-禁用',
     wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    corp_id VARCHAR(64) COMMENT '所属企微企业 corp_id(为空回退默认授权企业)',
     wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
@@ -236,6 +237,21 @@ PREPARE stmt FROM @sql;
 EXECUTE stmt;
 DEALLOCATE PREPARE stmt;
 
+-- 兼容已存在的数据库:补充用户所属企微企业字段(多企业消息/免登路由用)
+SET @sql = (
+    SELECT IF(COUNT(*) = 0,
+        'ALTER TABLE sys_user ADD COLUMN corp_id VARCHAR(64) COMMENT ''所属企微企业 corp_id(为空回退默认授权企业)''',
+        'SELECT 1'
+    )
+    FROM INFORMATION_SCHEMA.COLUMNS
+    WHERE TABLE_NAME = 'sys_user'
+      AND COLUMN_NAME = 'corp_id'
+      AND TABLE_SCHEMA = DATABASE()
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
 -- ----------------------------
 -- 9. 通知配置表
 -- ----------------------------

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

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS sys_user (
     employee_type VARCHAR(20) DEFAULT 'common_user',
     status TINYINT DEFAULT 0,
     wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID',
+    corp_id VARCHAR(64) COMMENT '所属企微企业 corp_id(为空回退默认授权企业)',
     wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是',
     create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -155,6 +156,7 @@ CREATE TABLE IF NOT EXISTS bpm_attachment (
 -- 兼容已存在的数据库:补充企业微信字段
 ALTER TABLE sys_user ADD COLUMN wecom_user_id VARCHAR(100) COMMENT '企业微信用户ID';
 ALTER TABLE sys_user ADD COLUMN wecom_remind_enabled TINYINT DEFAULT 1 COMMENT '是否开启企微提醒:0-否 1-是';
+ALTER TABLE sys_user ADD COLUMN corp_id VARCHAR(64) COMMENT '所属企微企业 corp_id(为空回退默认授权企业)';
 
 -- 通知配置表(预留:供企业微信/邮件/短信等渠道配置读取)
 CREATE TABLE IF NOT EXISTS sys_notification_config (