3 Commits 37ced18c08 ... 82c0bed303

Tác giả SHA1 Thông báo Ngày
  wuwenyi 82c0bed303 Merge branch 'feature/wl-coupon-multicorp' 1 tuần trước cách đây
  wuwenyi 79f39f34b6 feat: 商户 qqflow 流程管理员账号脚本(scrm_service/智和诚/威马/五菱新事业) 2 tuần trước cách đây
  wuwenyi 754aa9fd9d feat: 企微通知/免登支持多企业路由(corp_id + agent_id 维度) 2 tuần trước cách đây
22 tập tin đã thay đổi với 763 bổ sung và 50 xóa
  1. 129 0
      sql/20261027_qqflow_create_scrm_service_account.sql
  2. 165 0
      sql/20261028_qqflow_create_zhc_wm_merchant_accounts.sql
  3. 114 0
      sql/20261030_qqflow_create_wlxsy_merchant_account.sql
  4. 83 0
      sql/20261030_qqflow_multi_corp_routing.sql
  5. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/assemble/EnterpriseWechatAssembler.java
  6. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/component/EnterpriseWechatEventHandler.java
  7. 3 2
      src/main/java/com/qqflow/engine/domain/ewechat/controller/WecomOAuthController.java
  8. 7 0
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.java
  9. 3 2
      src/main/java/com/qqflow/engine/domain/ewechat/mapper/EnterpriseWechatCorpMapper.xml
  10. 18 0
      src/main/java/com/qqflow/engine/domain/ewechat/model/enterprise/rsp/EnterpriseWechatPermanentCodeRsp.java
  11. 5 0
      src/main/java/com/qqflow/engine/domain/ewechat/po/EnterpriseWechatCorp.java
  12. 89 27
      src/main/java/com/qqflow/engine/domain/ewechat/service/EnterpriseWechatMsgService.java
  13. 77 12
      src/main/java/com/qqflow/engine/domain/ewechat/service/WecomOAuthService.java
  14. 4 3
      src/main/java/com/qqflow/engine/domain/system/controller/SysUserController.java
  15. 3 0
      src/main/java/com/qqflow/engine/domain/system/dto/BindWeComDTO.java
  16. 3 0
      src/main/java/com/qqflow/engine/domain/system/entity/SysUser.java
  17. 17 0
      src/main/java/com/qqflow/engine/domain/system/mapper/SysUserMapper.java
  18. 5 2
      src/main/java/com/qqflow/engine/domain/system/service/SysUserService.java
  19. 9 2
      src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java
  20. 1 0
      src/main/resources/schema-dev.sql
  21. 16 0
      src/main/resources/schema-mysql.sql
  22. 2 0
      src/main/resources/schema-test.sql

+ 129 - 0
sql/20261027_qqflow_create_scrm_service_account.sql

@@ -0,0 +1,129 @@
+-- =====================================================================
+-- 为 wl-scrm 后端创建 qqflow 专用服务账号
+--
+-- 执行库:qqflow 审批引擎库(qqflowengine)
+-- 用途:wl-scrm 后端内部调用 qqflow(发起/撤回审批、同步进度、
+--       审批人配置页「同步选中」创建审批人账号/绑定企微)
+--
+-- 为什么需要 super_admin:
+--   · POST /system/user(创建审批人账号)要求 super_admin 或 dept_manager;
+--   · PUT /system/user/{id}/wecom(给已有账号补绑企微)仅允许 super_admin;
+--   · 只授 flow_manager 时,「同步选中」会报「权限不足,无法访问」。
+--
+-- 账号约定:
+--   username = scrm_service
+--   password = 自定义强密码(与 wl-scrm 外置配置 qqflow.service.password 一致)
+--
+-- qqflow 密码使用 BCrypt 存储,先生成哈希再替换下面的占位符:
+--   htpasswd -bnBC 10 "" '你的密码' | tr -d ':\n'
+--
+-- 注意:
+--   · 本脚本创建的是高权限服务账号,密码只放 wl-scrm 服务端外置配置,
+--     不要提交仓库、不要发到群里;
+--   · 脚本幂等:重复执行会重置该账号密码并补齐 super_admin 角色;
+--   · 如果 username 已被无关账号占用,第 0 步会先查出来,切勿直接覆盖。
+-- =====================================================================
+
+-- 执行前确认当前库:必须是 qqflowengine。
+-- 如果在 wlscrmcenter 或测试库执行,生产 qqflow 里不会出现这个账号。
+SELECT DATABASE() AS current_database;
+
+SET @service_username = 'scrm_service';
+-- 替换为 htpasswd 生成的 BCrypt 哈希(60 位,$2a$/$2b$/$2y$ 开头)
+SET @service_password_hash = '<替换为 BCrypt 哈希>';
+SET @super_admin_role_code = 'super_admin';
+
+-- 0) 执行前检查:用户名是否已被占用
+--    有结果时先确认是不是本次要重置的账号;如果是别人在用的账号,请换一个 username。
+SELECT id, username, real_name, employee_type, status
+FROM sys_user
+WHERE username COLLATE utf8mb4_general_ci = @service_username COLLATE utf8mb4_general_ci;
+
+-- 1) 校验 super_admin 角色存在且启用
+--    生产当前应有 id=1 的 super_admin;若没有,请先确认 qqflow 角色模型,不要继续。
+SELECT id, role_code, role_name, role_scope, status
+FROM sys_role
+WHERE role_code COLLATE utf8mb4_general_ci = @super_admin_role_code COLLATE utf8mb4_general_ci;
+
+-- 2.1) 更新已有账号(仅限本脚本管理的账号)
+--       只有 real_name 或 employee_type 对得上才更新,避免误改同名但无关的账号
+UPDATE sys_user
+SET password = @service_password_hash,
+    real_name = 'SCRM 服务账号',
+    employee_type = @super_admin_role_code,
+    status = 0,
+    wecom_user_id = NULL,
+    wecom_remind_enabled = 0,
+    update_time = NOW()
+WHERE username COLLATE utf8mb4_general_ci = @service_username COLLATE utf8mb4_general_ci
+  AND (real_name = 'SCRM 服务账号' OR employee_type = @super_admin_role_code);
+
+-- 2.2) 不存在则创建
+--      若 username 已被无关账号占用,这里不会插入;第 0 步/第 4 步会显示
+--      该账号的原状态,此时不要继续,换一个 username 后重跑。
+INSERT INTO sys_user
+    (username, password, real_name, dept_id, employee_type, status, wecom_remind_enabled)
+SELECT @service_username, @service_password_hash, 'SCRM 服务账号', NULL,
+       @super_admin_role_code, 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @service_username COLLATE utf8mb4_general_ci
+);
+
+-- 3) 绑定 super_admin 角色(接口鉴权使用)
+--    只取「本脚本管理的账号」:如果 username 被无关账号占用,这里取不到 id,
+--    不会给无关账号误绑 super_admin;校验结果会显示该账号仍是原 employee_type。
+SET @service_user_id = (
+    SELECT id FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @service_username COLLATE utf8mb4_general_ci
+      AND (real_name = 'SCRM 服务账号' OR employee_type = @super_admin_role_code)
+    LIMIT 1
+);
+SET @super_admin_role_id = (
+    SELECT id FROM sys_role
+    WHERE role_code COLLATE utf8mb4_general_ci = @super_admin_role_code COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+INSERT INTO sys_user_role (user_id, role_id)
+SELECT @service_user_id, @super_admin_role_id
+WHERE @service_user_id IS NOT NULL
+  AND @super_admin_role_id IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM sys_user_role
+      WHERE user_id = @service_user_id
+        AND role_id = @super_admin_role_id
+  );
+
+-- 4) 校验:应返回 1 行:
+--    employee_type=super_admin、role_code=super_admin、
+--    pwd_len=60、pwd_prefix=$2a$/$2b$/$2y$(不是 60 位说明哈希没替换/复制错)
+SELECT u.id            AS user_id,
+       u.username,
+       u.employee_type,
+       u.status        AS user_status,
+       CHAR_LENGTH(u.password) AS pwd_len,
+       LEFT(u.password, 4)     AS pwd_prefix,
+       r.id            AS role_id,
+       r.role_code,
+       r.role_name,
+       r.status        AS role_status
+FROM sys_user u
+LEFT JOIN sys_user_role ur ON ur.user_id = u.id
+LEFT JOIN sys_role r ON r.id = ur.role_id
+WHERE u.username COLLATE utf8mb4_general_ci = @service_username COLLATE utf8mb4_general_ci;
+
+-- =====================================================================
+-- 执行后还需要在 wl-scrm 生产外置配置(config/application-prod.properties
+-- 或环境变量)中配置,并重启 wl-scrm:
+--
+--   qqflow.service.username=scrm_service
+--   qqflow.service.password=<与上面的明文密码一致>
+--
+-- 如果之前用过其它服务账号,清掉 wl-scrm Redis 中的旧 token(JWT 角色是
+-- 登录时写入的,缓存 23 小时;改密码/改角色后必须清):
+--
+--   redis-cli DEL "TOKEN:QQFLOW:SERVICE:default:scrm_service"
+--   redis-cli DEL "TOKEN:QQFLOW:SERVICE:default:<旧账号>"
+-- =====================================================================

+ 165 - 0
sql/20261028_qqflow_create_zhc_wm_merchant_accounts.sql

@@ -0,0 +1,165 @@
+-- =====================================================================
+-- 为「四川智和诚」「湖北威马」商户创建 qqflow 流程管理员账号
+--
+-- 执行库:qqflow 审批引擎库(qqflowengine)
+-- 执行顺序:先执行本脚本,再在 wl-scrm 库执行
+--           wl-scrm-center/sql/20261028_wlscrm_bind_zhc_wm_qqflow_accounts.sql
+--
+-- 账号约定(两个脚本必须一致):
+--   四川智和诚  username = zhc_flow,明文密码 = zhc123456
+--   湖北威马    username = wm_flow ,明文密码 = wm123456
+--
+-- qqflow 密码使用 BCrypt 存储,哈希由下列命令生成(勿替换成明文):
+--   htpasswd -bnBC 10 "" 'zhc123456' | tr -d ':\n'
+--   htpasswd -bnBC 10 "" 'wm123456'  | tr -d ':\n'
+--
+-- 说明:
+--   · qqflow 前端菜单看 sys_user.employee_type,必须为 flow_manager;
+--   · qqflow 后端接口 @PreAuthorize 看 sys_user_role → sys_role.role_code,
+--     必须同时绑定 flow_manager 角色(本脚本会补齐);
+--   · 脚本幂等:重复执行会重置账号密码并补齐角色,不会重复插入。
+-- =====================================================================
+
+-- 执行前确认当前库:必须是 qqflowengine。
+SELECT DATABASE() AS current_database;
+
+-- -------------------------------------------------------------------
+-- 0) 公共准备:确保 flow_manager 角色存在且启用
+-- -------------------------------------------------------------------
+INSERT INTO sys_role (role_code, role_name, role_scope, parent_id, dept_id, status)
+SELECT 'flow_manager', '流程管理员', 'tenant', 0, 0, 1
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_role
+    WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci
+);
+
+UPDATE sys_role
+SET status = 1
+WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci;
+
+SET @flow_manager_role_id = (
+    SELECT id FROM sys_role
+    WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+-- =====================================================================
+-- 1) 四川智和诚(tenancy_id = 119304923710091)
+-- =====================================================================
+SET @username = 'zhc_flow';
+SET @password_hash = '$2y$10$PffgaT28otYLtLyo5nftKOoQW8Rl9PekZKJDGVXnb9klH1LeoUcUa';
+SET @real_name = '四川智和诚流程管理员';
+
+-- 1.0 执行前检查:用户名是否已被占用
+--     有结果时先确认是不是本次要重置的账号;如果是别人在用的账号,请换一个 username。
+SELECT id, username, real_name, employee_type, status
+FROM sys_user
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci;
+
+-- 1.1 更新已有账号(仅限本脚本管理的账号)
+--     只有 real_name 或 employee_type 对得上才更新,避免误改同名但无关的账号
+UPDATE sys_user
+SET password = @password_hash,
+    real_name = @real_name,
+    employee_type = 'flow_manager',
+    status = 0,
+    update_time = NOW()
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+  AND (real_name = @real_name OR employee_type = 'flow_manager');
+
+-- 1.2 不存在则创建
+--     若 username 已被无关账号占用,这里会因唯一键失败而中断(第 1.0 步会先提示)
+INSERT INTO sys_user
+    (username, password, real_name, dept_id, employee_type, status, wecom_remind_enabled)
+SELECT @username, @password_hash, @real_name, 0, 'flow_manager', 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+);
+
+-- 1.3 绑定 flow_manager 角色(后端接口鉴权使用)
+SET @user_id = (
+    SELECT id FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+INSERT INTO sys_user_role (user_id, role_id)
+SELECT @user_id, @flow_manager_role_id
+WHERE @user_id IS NOT NULL
+  AND @flow_manager_role_id IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM sys_user_role
+      WHERE user_id = @user_id
+        AND role_id = @flow_manager_role_id
+  );
+
+-- =====================================================================
+-- 2) 湖北威马(tenancy_id = 119306205010001)
+-- =====================================================================
+SET @username = 'wm_flow';
+SET @password_hash = '$2y$10$9EIvGo/0AFwdrPzglk9OBuLXRzyfowiI4nzm8Gx1euh1ZDkN05vyq';
+SET @real_name = '湖北威马流程管理员';
+
+-- 2.0 执行前检查:用户名是否已被占用
+SELECT id, username, real_name, employee_type, status
+FROM sys_user
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci;
+
+-- 2.1 更新已有账号(仅限本脚本管理的账号)
+UPDATE sys_user
+SET password = @password_hash,
+    real_name = @real_name,
+    employee_type = 'flow_manager',
+    status = 0,
+    update_time = NOW()
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+  AND (real_name = @real_name OR employee_type = 'flow_manager');
+
+-- 2.2 不存在则创建
+INSERT INTO sys_user
+    (username, password, real_name, dept_id, employee_type, status, wecom_remind_enabled)
+SELECT @username, @password_hash, @real_name, 0, 'flow_manager', 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+);
+
+-- 2.3 绑定 flow_manager 角色
+SET @user_id = (
+    SELECT id FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+INSERT INTO sys_user_role (user_id, role_id)
+SELECT @user_id, @flow_manager_role_id
+WHERE @user_id IS NOT NULL
+  AND @flow_manager_role_id IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM sys_user_role
+      WHERE user_id = @user_id
+        AND role_id = @flow_manager_role_id
+  );
+
+-- =====================================================================
+-- 3) 校验:应返回 2 行,employee_type 均为 flow_manager,
+--    role_code 均为 flow_manager,pwd_len=60、pwd_prefix=$2y$
+-- =====================================================================
+SELECT u.id            AS user_id,
+       u.username,
+       u.employee_type,
+       u.status        AS user_status,
+       CHAR_LENGTH(u.password) AS pwd_len,
+       LEFT(u.password, 4)     AS pwd_prefix,
+       r.id            AS role_id,
+       r.role_code,
+       r.role_name,
+       r.status        AS role_status
+FROM sys_user u
+LEFT JOIN sys_user_role ur ON ur.user_id = u.id
+LEFT JOIN sys_role r ON r.id = ur.role_id
+WHERE u.username COLLATE utf8mb4_general_ci IN ('zhc_flow', 'wm_flow')
+ORDER BY u.username;

+ 114 - 0
sql/20261030_qqflow_create_wlxsy_merchant_account.sql

@@ -0,0 +1,114 @@
+-- =====================================================================
+-- 为「五菱新事业」商户创建 qqflow 流程管理员账号
+--
+-- 执行库:qqflow 审批引擎库(qqflowengine)
+-- 执行顺序:先执行本脚本,再在 wl-scrm 库执行
+--           wl-scrm-center/sql/20261030_wlscrm_bind_wlxsy_qqflow_account.sql
+--
+-- 账号约定(两个脚本必须一致):
+--   五菱新事业  username = wlxsy_flow,明文密码 = wlxsy123456
+--
+-- qqflow 密码使用 BCrypt 存储,哈希由下列命令生成(勿替换成明文):
+--   htpasswd -bnBC 10 "" 'wlxsy123456' | tr -d ':\n'
+--
+-- 说明:
+--   · qqflow 前端菜单看 sys_user.employee_type,必须为 flow_manager;
+--   · qqflow 后端接口 @PreAuthorize 看 sys_user_role → sys_role.role_code,
+--     必须同时绑定 flow_manager 角色(本脚本会补齐);
+--   · 脚本幂等:重复执行会重置账号密码并补齐角色,不会重复插入。
+-- =====================================================================
+
+-- 执行前确认当前库:必须是 qqflowengine。
+SELECT DATABASE() AS current_database;
+
+-- -------------------------------------------------------------------
+-- 0) 公共准备:确保 flow_manager 角色存在且启用
+-- -------------------------------------------------------------------
+INSERT INTO sys_role (role_code, role_name, role_scope, parent_id, dept_id, status)
+SELECT 'flow_manager', '流程管理员', 'tenant', 0, 0, 1
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_role
+    WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci
+);
+
+UPDATE sys_role
+SET status = 1
+WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci;
+
+SET @flow_manager_role_id = (
+    SELECT id FROM sys_role
+    WHERE role_code COLLATE utf8mb4_general_ci = 'flow_manager' COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+-- =====================================================================
+-- 1) 五菱新事业(tenancy_id = 118216101210041)
+-- =====================================================================
+SET @username = 'wlxsy_flow';
+SET @password_hash = '$2y$10$2IxmPKN7vqGy4tW32jxFYeMixOBZls4NEiAnz/WDfpUwoiezWp9n.';
+SET @real_name = '五菱新事业流程管理员';
+
+-- 1.0 执行前检查:用户名是否已被占用
+--     有结果时先确认是不是本次要重置的账号;如果是别人在用的账号,请换一个 username。
+SELECT id, username, real_name, employee_type, status
+FROM sys_user
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci;
+
+-- 1.1 更新已有账号(仅限本脚本管理的账号)
+--     只有 real_name 或 employee_type 对得上才更新,避免误改同名但无关的账号
+UPDATE sys_user
+SET password = @password_hash,
+    real_name = @real_name,
+    employee_type = 'flow_manager',
+    status = 0,
+    update_time = NOW()
+WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+  AND (real_name = @real_name OR employee_type = 'flow_manager');
+
+-- 1.2 不存在则创建
+--     若 username 已被无关账号占用,这里会因唯一键失败而中断(第 1.0 步会先提示)
+INSERT INTO sys_user
+    (username, password, real_name, dept_id, employee_type, status, wecom_remind_enabled)
+SELECT @username, @password_hash, @real_name, 0, 'flow_manager', 0, 0
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+);
+
+-- 1.3 绑定 flow_manager 角色(后端接口鉴权使用)
+SET @user_id = (
+    SELECT id FROM sys_user
+    WHERE username COLLATE utf8mb4_general_ci = @username COLLATE utf8mb4_general_ci
+    LIMIT 1
+);
+
+INSERT INTO sys_user_role (user_id, role_id)
+SELECT @user_id, @flow_manager_role_id
+WHERE @user_id IS NOT NULL
+  AND @flow_manager_role_id IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM sys_user_role
+      WHERE user_id = @user_id
+        AND role_id = @flow_manager_role_id
+  );
+
+-- =====================================================================
+-- 2) 校验:应返回 1 行,employee_type 为 flow_manager,
+--    role_code 为 flow_manager,pwd_len=60、pwd_prefix=$2y$
+-- =====================================================================
+SELECT u.id            AS user_id,
+       u.username,
+       u.employee_type,
+       u.status        AS user_status,
+       CHAR_LENGTH(u.password) AS pwd_len,
+       LEFT(u.password, 4)     AS pwd_prefix,
+       r.id            AS role_id,
+       r.role_code,
+       r.role_name,
+       r.status        AS role_status
+FROM sys_user u
+LEFT JOIN sys_user_role ur ON ur.user_id = u.id
+LEFT JOIN sys_role r ON r.id = ur.role_id
+WHERE u.username COLLATE utf8mb4_general_ci = 'wlxsy_flow'
+ORDER BY u.username;

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