feat(wxcp): 新增企业微信模块,支持成员扫码登录、活动通知与通讯录

一、独立模块 yudao-module-wxcp
- 企业微信扫码登录(JustAuth)+ 首次扫码自动注册账号(用户名 wx_ 前缀)
- 应用消息推送,抽成 WxCpMessagePushService 供活动/通知复用
- 通讯录查看:部门树、部门平铺、成员列表,用于推送时挑选目标
- 回调接口(URL 验证 + 消息接收),本次未启用接收消息

二、活动管理
- 创建/编辑/发布/结束/取消,活动状态 草稿-已发布-已结束-已取消
- 推送:企业微信文本卡片消息,按钮直达报名页
- 报名二维码:后端生成 PNG,扫码打开 H5 报名页
- 报名页支持填写参与说明并上传图片/附件,免登录
- 报名统计:总数、状态分布、按部门与日期分布、来源分布、签到率

三、通知管理
- 创建/编辑/发布/撤回/推送,与活动共用推送服务
- 通知用纯文本推送(textcard 类型强制要求 url,通知无详情页)

四、跨模块与规范
- 报名附件走 system 模块附件中间表,未新增 upload 接口、未加文件地址字段
- StorageRecordTypeEnum 注册 wxcp_activity_signup,否则绑定报「无效的记录类型」
- 登录通知通过 Spring 事件解耦,避免 wxcp 与 system 循环依赖
- 监听用 AFTER_COMMIT,避免异步线程读不到未提交的绑定记录

五、数据库脚本(docs/sql)
- wxcp_module.sql:活动/报名/通知三张表
- wxcp_menu.sql:菜单与角色授权,幂等
- config_export_all_20260929.sql:全库初始化脚本,新增「阶段三」企业微信增量段
(因企业微信菜单只存在于业务库 mom-jssm,源库无此数据,无法按阶段二的跨库方式补导)

六、注意:以下配置未包含在本提交中,部署时需自行补齐

yudao-server/src/main/resources/application-local.yaml
- wx.cp.corp-id / agent-id / secret / token / aes-key
(企业微信企业ID、应用ID、应用密钥、回调令牌、回调加解密密钥)
- wx.cp.signup-url-prefix(活动报名页外网地址前缀,二维码指向它)
- wx.cp.qrcode-size
以上均为环境相关配置,刻意不入库。

yudao-server/src/main/resources/application.yaml
- yudao.security.permit-all_urls 需追加三条免登录白名单,否则对应功能不可用:
/admin-api/wxcp/callback/** 企业微信回调
/admin-api/wxcp/activity/signup-page 活动报名落地页(扫码打开)
/admin-api/wxcp/activity/signup 活动报名提交
/admin-api/system/storage-blob/public-upload 报名页附件上传

Co-Authored-By: Claude <noreply@anthropic.com>
已添加51个文件
已修改5个文件
16111 ■■■■■ 文件已修改
docs/sql/config_export_all_20260929.sql 11638 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/wxcp_menu.sql 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/wxcp_module.sql 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/wxcp_activity_integration.md 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/wxcp_login_integration.md 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-dependencies/pom.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/event/SocialLoginEvent.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/pom.xml 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityPageReqVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityStatsRespVO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPageReqVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupRespVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/callback/WxCpCallbackController.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/WxCpMemberController.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/vo/WxCpDepartmentRespVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/vo/WxCpMemberRespVO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticePageReqVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushResultVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupDO.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeDO.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivityMapper.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivitySignupMapper.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/notice/WxCpNoticeMapper.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpActivityStatusEnum.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpPushScopeEnum.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpSignupStatusEnum.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeStatusEnum.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/config/WxCpProperties.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/config/YudaoWxCpConfiguration.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/core/WxCpClientFactory.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/qrcode/WxCpQrCodeUtil.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java 473 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/listener/WxCpLoginEventListener.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberService.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberServiceImpl.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeService.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notify/WxCpNotifyService.java 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/pom.xml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260929.sql
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
docs/sql/wxcp_menu.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
-- ============================================================
-- ä¼ä¸šå¾®ä¿¡æ¨¡å—菜单与权限
-- ç›®æ ‡åº“:mom-jssm(36.213.150.237:10000)
--
-- ä½œç”¨ï¼š
--   1. è®© /wxcp/activity、/wxcp/notice ä¸¤ä¸ªé¡µé¢èƒ½åœ¨å·¦ä¾§èœå•中出现
--      ï¼ˆå‰ç«¯è·¯ç”±æ˜¯èœå•驱动的,没有菜单记录页面就访问不到)
--   2. æ³¨å†Œ @PreAuthorize ç”¨åˆ°çš„æƒé™æ ‡è¯†ï¼Œå¦åˆ™æŽ¥å£ä¸€å¾‹ 403
--
-- èœå•类型:1 ç›®å½•、2 èœå•、3 æŒ‰é’®
-- ç¼–号段:6100000 èµ·ï¼Œä¸ŽçŽ°æœ‰èœå•ä¸å†²çª
--
-- å¹‚等:先按编号删除再插入,可重复执行
-- ============================================================
DELETE FROM `system_menu` WHERE `id` BETWEEN 6100000 AND 6100999;
-- ----------------------------
-- ä¸€çº§ç›®å½•:企业微信
-- ----------------------------
INSERT INTO `system_menu`
  (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
   `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (6100000, '企业微信', '', 1, 80, 0, '/wxcp', 'ep:chat-dot-round', NULL, NULL,
   0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0');
-- ----------------------------
-- æ´»åŠ¨ç®¡ç†
-- ----------------------------
INSERT INTO `system_menu`
  (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
   `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (6100001, '活动管理', '', 2, 1, 6100000, 'activity', 'ep:calendar', '/wxcp/activity/index', 'WxCpActivityList',
   0, b'1', b'1', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100010, '活动查询', 'wxcp:activity:query',  3, 1, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100011, '活动新增', 'wxcp:activity:create', 3, 2, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100012, '活动修改', 'wxcp:activity:update', 3, 3, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100013, '活动删除', 'wxcp:activity:delete', 3, 4, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100014, '活动推送', 'wxcp:activity:push',   3, 5, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  -- æŠ¥åè®°å½•挂在活动菜单下,因为它只能从活动详情进入
  (6100015, '报名查询', 'wxcp:signup:query',    3, 6, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100016, '报名管理', 'wxcp:signup:update',   3, 7, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  -- é€šè®¯å½•在推送弹窗里用,不单独建页面,但权限要注册
  (6100017, '通讯录查询', 'wxcp:member:query',  3, 8, 6100001, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0');
-- ----------------------------
-- é€šçŸ¥ç®¡ç†
-- ----------------------------
INSERT INTO `system_menu`
  (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`,
   `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (6100002, '通知管理', '', 2, 2, 6100000, 'notice', 'ep:bell', '/wxcp/notice/index', 'WxCpNoticeList',
   0, b'1', b'1', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100020, '通知查询', 'wxcp:notice:query',  3, 1, 6100002, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100021, '通知新增', 'wxcp:notice:create', 3, 2, 6100002, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100022, '通知修改', 'wxcp:notice:update', 3, 3, 6100002, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100023, '通知删除', 'wxcp:notice:delete', 3, 4, 6100002, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0'),
  (6100024, '通知推送', 'wxcp:notice:push',   3, 5, 6100002, '', '', '', '', 0, b'1', b'0', b'1', 'admin', NOW(), 'admin', NOW(), b'0');
-- ============================================================
-- è§’色授权
--
-- åˆ†é…æƒ¯ä¾‹æ²¿ç”¨çŽ°æœ‰æ¨¡å—ï¼ˆå¦‚å”®åŽç®¡ç†ï¼‰ï¼š
--   super_admin(1) ä¸Ž 001(160) æ‹¿ã€Œæ•´ä¸ªæ¨¡å—的全部菜单」
--   å…¶ä»–角色不自动授予,需要时在「系统管理 â†’ è§’色管理」里手动勾选
--
-- æ³¨ï¼šsuper_admin ç™»å½•时代码会直接返回全部菜单,这里的映射是为了
--     è®© system_role_menu æ•°æ®å®Œæ•´ï¼Œä¸Žå…¶å®ƒæ¨¡å—保持一致。
-- ============================================================
DELETE `rm` FROM `system_role_menu` `rm`
WHERE `rm`.`role_id` IN (1, 160)
  AND `rm`.`menu_id` BETWEEN 6100000 AND 6100999;
INSERT INTO `system_role_menu`
  (`role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
SELECT `r`.`id`, `m`.`id`, 'admin', NOW(), 'admin', NOW(), b'0', 0
FROM `system_role` `r`
JOIN `system_menu` `m`
  ON `m`.`id` BETWEEN 6100000 AND 6100999
 AND `m`.`deleted` = b'0'
WHERE `r`.`id` IN (1, 160)
  AND `r`.`deleted` = b'0';
-- ============================================================
-- å¦‚需给其它角色开通,把上一条 INSERT çš„ r.id IN (1, 160)
-- æ¢æˆç›®æ ‡è§’色编号即可,例如:
--   WHERE `r`.`id` IN (1, 160, 2)   -- è¿½åŠ ç»™ã€Œæ™®é€šè§’è‰²ã€
-- ============================================================
docs/sql/wxcp_module.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
-- ============================================================
-- ä¼ä¸šå¾®ä¿¡æ¨¡å—建表脚本(活动 / æŠ¥å / é€šçŸ¥ï¼‰
-- ç›®æ ‡åº“:mom-jssm(36.213.150.237:10000)
-- è¯´æ˜Žï¼šé¡¹ç›®ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼Œå› æ­¤ä¸å« tenant_id å­—段
--
-- æŠ¥åæäº¤çš„图片/附件不落本模块的表:
-- ç»Ÿä¸€èµ° system_storage_attachment ä¸­é—´è¡¨ï¼Œrecord_type = 'wxcp_activity_signup'
-- ============================================================
-- ----------------------------
-- æ´»åŠ¨ä¸»è¡¨
-- ----------------------------
CREATE TABLE IF NOT EXISTS `wxcp_activity` (
  `id`                 bigint       NOT NULL AUTO_INCREMENT                COMMENT '编号',
  `title`              varchar(128) NOT NULL                               COMMENT '活动标题',
  `content`            text                                                COMMENT '活动详情',
  `cover_url`          varchar(512)          DEFAULT ''                   COMMENT '封面图地址',
  `location`           varchar(255)          DEFAULT ''                   COMMENT '活动地点',
  `start_time`         datetime              DEFAULT NULL                 COMMENT '活动开始时间',
  `end_time`           datetime              DEFAULT NULL                 COMMENT '活动结束时间',
  `signup_start_time`  datetime              DEFAULT NULL                 COMMENT '报名开始时间',
  `signup_end_time`    datetime              DEFAULT NULL                 COMMENT '报名结束时间',
  `signup_limit`       int          NOT NULL DEFAULT 0                     COMMENT '报名人数上限,0 è¡¨ç¤ºä¸é™',
  `signup_count`       int          NOT NULL DEFAULT 0                     COMMENT '已报名人数(冗余,便于列表展示)',
  `status`             tinyint      NOT NULL DEFAULT 0                     COMMENT '状态:0草稿 1已发布 2已结束 3已取消',
  `push_scope`         tinyint      NOT NULL DEFAULT 0                     COMMENT '推送范围:0全员 1指定部门 2指定成员',
  `push_target`        varchar(1024)         DEFAULT ''                   COMMENT '推送目标:部门ID或成员账号,多个用逗号分隔',
  `push_status`        tinyint      NOT NULL DEFAULT 0                     COMMENT '推送状态:0未推送 1已推送',
  `push_time`          datetime              DEFAULT NULL                 COMMENT '最近推送时间',
  `remark`             varchar(512)          DEFAULT ''                   COMMENT '备注',
  `creator`            varchar(64)           DEFAULT ''                   COMMENT '创建者',
  `create_time`        datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP     COMMENT '创建时间',
  `updater`            varchar(64)           DEFAULT ''                   COMMENT '更新者',
  `update_time`        datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP     COMMENT '更新时间',
  `deleted`            bit(1)       NOT NULL DEFAULT b'0'                  COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_status` (`status`),
  KEY `idx_start_time` (`start_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信活动';
-- ----------------------------
-- æ´»åŠ¨æŠ¥åè®°å½•è¡¨
-- å”¯ä¸€æ€§ï¼ˆåŒä¸€æ´»åŠ¨åŒä¸€æˆå‘˜åªèƒ½æŠ¥ä¸€æ¬¡ï¼‰åœ¨ Service å±‚校验,不加唯一索引——
-- å› ä¸º deleted æ˜¯é€»è¾‘删除,唯一索引会与"取消报名后重新报名"冲突。
-- ----------------------------
CREATE TABLE IF NOT EXISTS `wxcp_activity_signup` (
  `id`            bigint       NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `activity_id`   bigint       NOT NULL                                   COMMENT '活动编号',
  `wx_user_id`    varchar(128)          DEFAULT ''                        COMMENT '企业微信成员账号(UserId),为空表示未通过企业微信授权直接填写表单',
  `name`          varchar(64)           DEFAULT ''                        COMMENT '姓名',
  `mobile`        varchar(32)           DEFAULT ''                        COMMENT '手机号',
  `dept_name`     varchar(255)          DEFAULT ''                        COMMENT '部门名称',
  `content`       text                                                    COMMENT '参与说明(成员提交的文字内容)',
  `status`        tinyint      NOT NULL DEFAULT 0                         COMMENT '状态:0已报名 1已取消 2已签到',
  `signup_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '报名时间',
  `check_in_time` datetime              DEFAULT NULL                      COMMENT '签到时间',
  `remark`        varchar(512)          DEFAULT ''                        COMMENT '备注',
  `creator`       varchar(64)           DEFAULT ''                        COMMENT '创建者',
  `create_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
  `updater`       varchar(64)           DEFAULT ''                        COMMENT '更新者',
  `update_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '更新时间',
  `deleted`       bit(1)       NOT NULL DEFAULT b'0'                      COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_activity_id` (`activity_id`),
  KEY `idx_wx_user_id` (`wx_user_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信活动报名记录';
-- ----------------------------
-- é€šçŸ¥è¡¨
-- ----------------------------
CREATE TABLE IF NOT EXISTS `wxcp_notice` (
  `id`            bigint       NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `title`         varchar(128) NOT NULL                                   COMMENT '通知标题',
  `content`       text                                                    COMMENT '通知正文',
  `status`        tinyint      NOT NULL DEFAULT 0                         COMMENT '状态:0草稿 1已发布 2已撤回',
  `push_scope`    tinyint      NOT NULL DEFAULT 0                         COMMENT '推送范围:0全员 1指定部门 2指定成员',
  `push_target`   varchar(1024)         DEFAULT ''                        COMMENT '推送目标:部门ID或成员账号,多个用逗号分隔',
  `push_status`   tinyint      NOT NULL DEFAULT 0                         COMMENT '推送状态:0未推送 1已推送',
  `push_time`     datetime              DEFAULT NULL                      COMMENT '最近推送时间',
  `remark`        varchar(512)          DEFAULT ''                        COMMENT '备注',
  `creator`       varchar(64)           DEFAULT ''                        COMMENT '创建者',
  `create_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
  `updater`       varchar(64)           DEFAULT ''                        COMMENT '更新者',
  `update_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '更新时间',
  `deleted`       bit(1)       NOT NULL DEFAULT b'0'                      COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信通知';
docs/wxcp_activity_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,248 @@
# ä¼ä¸šå¾®ä¿¡æ¨¡å—(活动 / é€šçŸ¥ / é€šè®¯å½•)- å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- æ´»åŠ¨ç®¡ç†ï¼ˆåˆ—è¡¨ + æ–°å»º/编辑弹窗)
- æ´»åŠ¨è¯¦æƒ…ï¼ˆå«æŠ¥åè®°å½•åˆ—è¡¨ã€æŠ¥åç»Ÿè®¡ã€äºŒç»´ç å±•ç¤ºï¼‰
- é€šçŸ¥ç®¡ç†ï¼ˆåˆ—表 + æ–°å»º/编辑弹窗)
- æˆå‘˜é€‰æ‹©å™¨ï¼ˆæŽ¨é€æ—¶æŒ‘选部门/成员,数据来自企业微信通讯录)
- æ´»åŠ¨æŠ¥åè½åœ°é¡µï¼ˆ**不需要前端开发**,由后端直接渲染 HTML,见文末说明)
## æœ¬æ¬¡æ–°å¢žèƒ½åŠ›
| èƒ½åŠ› | è¯´æ˜Ž |
|------|------|
| å‘起活动 | åˆ›å»ºè‰ç¨¿ â†’ ç¼–辑 â†’ å‘布 |
| æŽ¨é€ç»™æˆå‘˜ | é€šè¿‡ä¼ä¸šå¾®ä¿¡åº”用消息推送,成员点消息里的按钮直达报名页 |
| æŠ¥åäºŒç»´ç  | åŽç«¯ç”Ÿæˆ PNG,可后台展示/下载打印 |
| æ‰«ç æŠ¥å | æˆå‘˜æ‰«ç æ‰“开报名页,填姓名+手机号提交,**免登录** |
## API
所有接口前缀 `/admin-api`。
### æ´»åŠ¨ç®¡ç†
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| POST | `/wxcp/activity/create` | `wxcp:activity:create` | åˆ›å»ºæ´»åŠ¨ï¼ˆå­˜ä¸ºè‰ç¨¿ï¼‰ |
| PUT | `/wxcp/activity/update` | `wxcp:activity:update` | ä¿®æ”¹æ´»åЍ |
| DELETE | `/wxcp/activity/delete` | `wxcp:activity:delete` | åˆ é™¤æ´»åЍ |
| GET | `/wxcp/activity/get` | `wxcp:activity:query` | æ´»åŠ¨è¯¦æƒ… |
| GET | `/wxcp/activity/page` | `wxcp:activity:query` | æ´»åŠ¨åˆ†é¡µ |
| PUT | `/wxcp/activity/publish` | `wxcp:activity:update` | å‘布活动 |
| PUT | `/wxcp/activity/finish` | `wxcp:activity:update` | ç»“束活动 |
| PUT | `/wxcp/activity/cancel` | `wxcp:activity:update` | å–消活动 |
**活动分页请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| pageNo | Integer | æ˜¯ | é¡µç  |
| pageSize | Integer | æ˜¯ | æ¯é¡µæ¡æ•° |
| title | String | å¦ | æ´»åŠ¨æ ‡é¢˜ï¼Œæ¨¡ç³ŠåŒ¹é… |
| status | Integer | å¦ | çŠ¶æ€ï¼š0草稿 1已发布 2已结束 3已取消 |
| pushStatus | Integer | å¦ | æŽ¨é€çŠ¶æ€ï¼š0未推送 1已推送 |
| startTime | DateTime[] | å¦ | æ´»åŠ¨å¼€å§‹æ—¶é—´èŒƒå›´ï¼Œæ ¼å¼ `yyyy-MM-dd HH:mm:ss` |
**创建/修改请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | ä¿®æ”¹æ—¶å¿…å¡« | ç¼–号 |
| title | String | æ˜¯ | æ´»åŠ¨æ ‡é¢˜ï¼Œâ‰¤128 å­—符 |
| content | String | å¦ | æ´»åŠ¨è¯¦æƒ… |
| coverUrl | String | å¦ | å°é¢å›¾åœ°å€ |
| location | String | å¦ | æ´»åŠ¨åœ°ç‚¹ï¼Œâ‰¤255 å­—符 |
| startTime | DateTime | å¦ | æ´»åŠ¨å¼€å§‹æ—¶é—´ |
| endTime | DateTime | å¦ | æ´»åŠ¨ç»“æŸæ—¶é—´ |
| signupStartTime | DateTime | å¦ | æŠ¥åå¼€å§‹æ—¶é—´ |
| signupEndTime | DateTime | å¦ | æŠ¥åç»“束时间 |
| signupLimit | Integer | æ˜¯ | æŠ¥åä¸Šé™ï¼Œ**不限传 0** |
| pushScope | Integer | æ˜¯ | æŽ¨é€èŒƒå›´ï¼š0全员 1指定部门 2指定成员 |
| pushTarget | String | å¦ | æŽ¨é€ç›®æ ‡ï¼Œé€—号分隔;范围选 1 æˆ– 2 æ—¶å¿…å¡« |
| remark | String | å¦ | å¤‡æ³¨ |
**活动响应新增字段:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| statusName | String | çŠ¶æ€åï¼Œå¦‚ã€Œå·²å‘å¸ƒã€ |
| pushScopeName | String | æŽ¨é€èŒƒå›´åï¼Œå¦‚「全员」 |
| signupCount | Integer | å·²æŠ¥åäººæ•° |
| pushStatus | Integer | 0未推送 1已推送 |
| pushTime | DateTime | æœ€è¿‘推送时间 |
### æŽ¨é€ä¸ŽäºŒç»´ç 
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| POST | `/wxcp/activity/push` | `wxcp:activity:push` | æŽ¨é€ç»™ä¼ä¸šå¾®ä¿¡æˆå‘˜ |
| GET | `/wxcp/activity/qrcode` | `wxcp:activity:query` | èŽ·å–æŠ¥åäºŒç»´ç ï¼ˆè¿”å›ž PNG) |
| GET | `/wxcp/activity/signup-url` | `wxcp:activity:query` | èŽ·å–æŠ¥åé¡µåœ°å€ |
**推送请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | æ´»åŠ¨ç¼–å· |
| pushScope | Integer | å¦ | è¦†ç›–活动上的推送范围,不传则沿用 |
| pushTarget | String | å¦ | è¦†ç›–活动上的推送目标,不传则沿用 |
**推送响应:**
```json
{ "code": 0, "data": { "success": true, "errCode": 0, "message": "推送成功" } }
```
**二维码接口:** ç›´æŽ¥è¿”回 `image/png` å­—节流,不是 JSON。
页面里把这个 URL å½“图片地址用即可(如 `<img :src="...">`),需要带令牌时自行拼查询参数。
### æŠ¥åè®°å½•
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| GET | `/wxcp/activity/signup/page` | `wxcp:signup:query` | æŠ¥åè®°å½•分页 |
| PUT | `/wxcp/activity/signup/cancel` | `wxcp:signup:update` | å–消报名 |
| PUT | `/wxcp/activity/signup/check-in` | `wxcp:signup:update` | ç­¾åˆ° |
**分页请求参数:** `activityId`、`name`(模糊)、`mobile`(模糊)、`status`
**响应字段:** `id`、`activityId`、`activityTitle`、`wxUserId`、`name`、`mobile`、`deptName`、`status`、`statusName`、`signupTime`、`checkInTime`、`remark`
### å…¬å¼€æŽ¥å£ï¼ˆå…ç™»å½•,前端**不需要**调用)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/wxcp/activity/signup-page?id={活动编号}` | æŠ¥åè½åœ°é¡µ HTML,由后端渲染 |
| POST | `/wxcp/activity/signup` | æäº¤æŠ¥åï¼ˆæ”¯æŒ `content` æ–‡å­— + `blobIds` é™„件) |
### æŠ¥åç»Ÿè®¡ä¸Žé™„ä»¶
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| GET | `/wxcp/activity/stats?id=` | `wxcp:activity:query` | æŠ¥åç»Ÿè®¡ |
| GET | `/wxcp/activity/signup/attachments?signupId=` | `wxcp:signup:query` | æŸæ¡æŠ¥åä¸Šä¼ çš„图片/附件 |
**统计响应:**
```json
{
  "activityId": 1, "activityTitle": "2026春季团建活动",
  "signupLimit": 50, "totalSignup": 1,
  "signedUpCount": 1, "checkedInCount": 0, "canceledCount": 0,
  "remaining": 49, "signupPercent": 2, "checkInPercent": 0,
  "deptStats":      [{ "name": "技术部", "count": 3 }],
  "dateStats":      [{ "name": "2026-09-29", "count": 1 }],
  "sourceStats":    [{ "name": "企业微信身份", "count": 0 }, { "name": "表单填写", "count": 1 }]
}
```
> `remaining` / `signupPercent` åœ¨æ´»åЍ**不限名额**(`signupLimit=0`)时为 `null`,前端要显示"不限"。
> ç»Ÿè®¡**不含已取消**的报名,所以 `totalSignup â‰  æŠ¥åè®°å½•总条数`。
### é€šçŸ¥ç®¡ç†
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| POST | `/wxcp/notice/create` | `wxcp:notice:create` | åˆ›å»ºé€šçŸ¥ï¼ˆå­˜ä¸ºè‰ç¨¿ï¼‰ |
| PUT | `/wxcp/notice/update` | `wxcp:notice:update` | ä¿®æ”¹é€šçŸ¥ |
| DELETE | `/wxcp/notice/delete` | `wxcp:notice:delete` | åˆ é™¤é€šçŸ¥ |
| GET | `/wxcp/notice/get` | `wxcp:notice:query` | é€šçŸ¥è¯¦æƒ… |
| GET | `/wxcp/notice/page` | `wxcp:notice:query` | é€šçŸ¥åˆ†é¡µ |
| PUT | `/wxcp/notice/publish` | `wxcp:notice:update` | å‘布通知 |
| PUT | `/wxcp/notice/revoke` | `wxcp:notice:update` | æ’¤å›žé€šçŸ¥ |
| POST | `/wxcp/notice/push` | `wxcp:notice:push` | æŽ¨é€é€šçŸ¥ |
**通知与活动的差异:**
| é¡¹ | æ´»åЍ | é€šçŸ¥ |
|----|------|------|
| æŽ¨é€æ¶ˆæ¯ç±»åž‹ | æ–‡æœ¬å¡ç‰‡ï¼ˆå¸¦ã€Œç«‹å³æŠ¥åã€æŒ‰é’®è·³æŠ¥åé¡µï¼‰ | çº¯æ–‡æœ¬ |
| æ­£æ–‡é•¿åº¦ | ç”±å¡ç‰‡æ¨¡æ¿é™åˆ¶ | è¶…过 600 å­—**自动截断**并提示到系统内查看 |
| çŠ¶æ€ | è‰ç¨¿/已发布/已结束/已取消 | è‰ç¨¿/已发布/已撤回 |
| æŽ¨é€å‰æ | å¿…须「已发布」 | å¿…须「已发布」 |
### ä¼ä¸šå¾®ä¿¡é€šè®¯å½•
| æ–¹æ³• | è·¯å¾„ | æƒé™æ ‡è¯† | è¯´æ˜Ž |
|------|------|----------|------|
| GET | `/wxcp/member/department/tree` | `wxcp:member:query` | éƒ¨é—¨æ ‘(含 children) |
| GET | `/wxcp/member/department/list` | `wxcp:member:query` | éƒ¨é—¨å¹³é“ºåˆ—表(做下拉更方便) |
| GET | `/wxcp/member/list?departmentId=&fetchChild=` | `wxcp:member:query` | æˆå‘˜åˆ—表 |
**成员响应字段:** `userId`(推送时填这个)、`name`、`mobile`、`email`、`avatar`、`position`、`gender`、`status`、`departmentIds`、`departmentNames`
> è¿™å‡ ä¸ªæŽ¥å£æ˜¯**实时调用企业微信**的,企业微信对通讯录接口有频率限制。前端请做缓存(如打开选择器时拉一次),**不要**在搜索框里每输入一个字符就请求一次。
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. æ–°å»ºæ´»åЍ â†’ ä¿å­˜ä¸º**草稿**,此时扫码报名会提示「报名已结束」
2. ç‚¹ã€Œå‘布」→ çŠ¶æ€å˜ã€Œå·²å‘å¸ƒã€ï¼Œæ­¤æ—¶äºŒç»´ç å’ŒæŠ¥åé¡µæ‰å¯ç”¨
3. ç‚¹ã€ŒæŽ¨é€ã€â†’ æˆå‘˜ä¼ä¸šå¾®ä¿¡æ”¶åˆ°å¡ç‰‡æ¶ˆæ¯ï¼Œæ ‡é¢˜ä¸ºæ´»åŠ¨æ ‡é¢˜ï¼Œæ­£æ–‡å«**活动时间 / åœ°ç‚¹ / æŠ¥åæˆªæ­¢ / åé¢**,按钮「立即报名」直达报名页
4. æˆå‘˜ç‚¹æŒ‰é’®æˆ–扫码 â†’ æ‰“开报名页 â†’ å¡«å§“名和手机号 â†’ æäº¤
5. æŠ¥åæˆåŠŸ â†’ æ´»åЍ `signupCount` è‡ªåЍ +1
6. åŽå°åœ¨æ´»åŠ¨è¯¦æƒ…çš„æŠ¥åè®°å½•åˆ—è¡¨é‡Œå¯çœ‹åˆ°è¯¥æˆå‘˜ï¼Œå¯æ‰§è¡Œã€Œå–æ¶ˆæŠ¥åã€ã€Œç­¾åˆ°ã€
**数据带入关系:**
| æ¥æº | ç›®æ ‡ | å¸¦å…¥å†…容 |
|------|------|----------|
| æ´»åŠ¨ç¼–è¾‘ä¿å­˜ | æŽ¨é€æ¶ˆæ¯ | æ ‡é¢˜ã€æ—¶é—´ã€åœ°ç‚¹ã€æŠ¥åæˆªæ­¢ã€åé¢ |
| æ´»åŠ¨ç¼–å· | äºŒç»´ç å†…容 | `{signupUrlPrefix}/admin-api/wxcp/activity/signup-page?id={id}` |
| æ‰«ç æŠ¥å | æŠ¥åè®°å½• | æ´»åŠ¨ç¼–å· |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| title | åˆ—表、详情 | æ´»åŠ¨æ ‡é¢˜ï¼Œç‚¹å‡»è¿›è¯¦æƒ… |
| statusName | åˆ—表、详情 | ç”¨åŽç«¯è¿”回的中文名,前端不要自己映射 |
| startTime ~ endTime | åˆ—表、详情 | æ´»åŠ¨æ—¶é—´èŒƒå›´ |
| signupCount / signupLimit | åˆ—表、详情 | `0/50` è¿™ç§å½¢å¼ï¼›ä¸Šé™ä¸º 0 æ—¶æ˜¾ç¤ºã€Œä¸é™ã€ |
| pushStatus / pushTime | åˆ—表、详情 | æœªæŽ¨é€ / å·²æŽ¨é€ + æ—¶é—´ |
| pushScopeName | è¯¦æƒ… | æŽ¨é€èŒƒå›´ä¸­æ–‡å |
| qrcode(图片) | è¯¦æƒ… | æŠ¥åäºŒç»´ç ï¼Œéœ€æ”¯æŒä¸‹è½½ |
| statusName(报名) | æŠ¥ååˆ—表 | å·²æŠ¥å / å·²å–消 / å·²ç­¾åˆ° |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| åˆ›å»ºæ´»åЍ | ä¸€å¾‹å­˜ä¸ºè‰ç¨¿ï¼Œä¸å¯ç›´æŽ¥åˆ›å»ºä¸ºå·²å‘布 |
| ä¿®æ”¹æ´»åЍ | å·²ç»“束、已取消的活动不可修改;草稿和已发布可改 |
| æŽ¨é€å‰æ | **活动必须是「已发布」状态**,草稿状态推送会报「活动尚未发布,请先发布后再推送」 |
| æŽ¨é€èŒƒå›´=全员 | ä½¿ç”¨ä¼ä¸šå¾®ä¿¡ `@all`,即应用可见范围内所有人 |
| æŽ¨é€èŒƒå›´=指定成员 | `pushTarget` å¡«æˆå‘˜è´¦å·ï¼ˆUserId),多个用**英文逗号**分隔 |
| æŽ¨é€èŒƒå›´=指定部门 | `pushTarget` å¡«éƒ¨é—¨ ID,多个用英文逗号分隔 |
| æŽ¨é€å¤±è´¥ | æŽ¥å£è¿”回 `success=false` + ä¼ä¸šå¾®ä¿¡é”™è¯¯ç ï¼Œ**不会抛异常**;前端应把 `message` ç›´æŽ¥æç¤ºç»™ç”¨æˆ· |
| æŽ¨é€æˆåŠŸåŽ | æ´»åЍ `pushStatus` ç½® 1、`pushTime` è®°å½•时间;重复推送会覆盖这两个字段 |
| æŠ¥åæ—¶é—´çª—口 | æœªåˆ° `signupStartTime` æˆ–已过 `signupEndTime` å‡ä¸èƒ½æŠ¥å |
| æŠ¥ååé¢ | `signupLimit=0` è¡¨ç¤ºä¸é™ï¼›å·²æ»¡æ—¶æç¤ºã€ŒæŠ¥åäººæ•°å·²æ»¡ã€ |
| é‡å¤æŠ¥å | æœ‰ä¼ä¸šå¾®ä¿¡èº«ä»½æŒ‰æˆå‘˜è´¦å·åˆ¤é‡ï¼›æ™®é€šæµè§ˆå™¨æŒ‰**手机号**判重 |
| å–消后重新报名 | å¤ç”¨åŽŸè®°å½•ï¼Œä¸ä¼šäº§ç”Ÿç¬¬äºŒæ¡åŽ†å² |
| å–消报名 | æ´»åЍ `signupCount` è‡ªåЍ -1 |
## æ³¨æ„äº‹é¡¹
- **二维码接口返回的是图片二进制,不是 JSON**,不要用普通 request å°è£…去取,直接用图片 URL。
- **`signupLimit` å¿…å¡«**,不限要传 `0` è€Œä¸æ˜¯ç•™ç©ºï¼Œå¦åˆ™ä¼šæ ¡éªŒå¤±è´¥ã€‚
- **推送范围选「指定部门/成员」时 `pushTarget` ä¸èƒ½ä¸ºç©º**,否则报「推送目标不能为空」。
- æŽ¨é€æ˜¯**同步**调用企业微信接口,成员多时可能耗时 1~3 ç§’,前端按钮要有 loading。
- æ´»åЍ `content` å­—段在报名页是**按纯文本渲染**的(做了 HTML è½¬ä¹‰é˜² XSS)。如果活动详情需要富文本排版,需要另行接入富文本白名单过滤,当前版本不支持。
- æŠ¥åé¡µåœ°å€ä¾èµ–后端配置 `wx.cp.signup-url-prefix`(当前为 `http://wcf.viphk.nnhk.cc`)。该地址必须是**成员手机能访问到的外网地址**,否则二维码扫出来打不开。
- æŠ¥åè®°å½•里的 `wxUserId` åªæœ‰åœ¨**企业微信内打开报名页且带上了参数**时才有值;普通浏览器扫码报名时为空,此时按手机号去重。
- æƒé™æ ‡è¯†éœ€è¦åœ¨ã€Œç³»ç»Ÿç®¡ç† â†’ èœå•管理」里配置好,否则接口会返回 403。
- **推送目标用的是 `userId`(成员账号,如 `LiuYi`),不是姓名、也不是手机号。** ä»Žé€šè®¯å½•接口取 `userId` å¡«è¿› `pushTarget`。
- **附件上传走 system æ¨¡å—的公共上传接口**:报名页先调 `POST /admin-api/system/storage-blob/public-upload`(表单字段名 `files`)拿到 `blobId`,再把 `blobIds` éšæŠ¥åæäº¤ï¼Œç”±**服务端**完成与报名记录的绑定。管理后台查附件用 `/wxcp/activity/signup/attachments`。
- **报名的 `recordType` å›ºå®šä¸º `wxcp_activity_signup`**,已注册进 system æ¨¡å—çš„ `StorageRecordTypeEnum` ç™½åå•。以后新增别的附件关联类型,必须同步注册,否则绑定会报「无效的记录类型」。
- é€šè®¯å½•接口**实时调企业微信**,有频率限制,前端务必缓存,不要随输入实时请求。
- é€šçŸ¥æ­£æ–‡è¶…过 600 å­—会**自动截断**(企业微信文本消息有长度上限),截断提示会写在消息末尾。
## å…³äºŽæŠ¥åè½åœ°é¡µ
报名页 `GET /admin-api/wxcp/activity/signup-page?id=xxx` ç”±**后端直接渲染 HTML**(含内联样式和少量 JS),前端**无需开发**。
这样做的原因:二维码必须指向一个成员手机能直接打开的页面,而后端渲染可以立即跑通「推送 â†’ æ‰«ç  â†’ æŠ¥åã€é—­çŽ¯ï¼Œä¸å¿…ç­‰å‰ç«¯æŽ’æœŸã€‚è‹¥åŽç»­å¸Œæœ›æ”¹ä¸ºå‰ç«¯ SPA é¡µé¢ï¼ŒæŠŠäºŒç»´ç å†…容改成前端路由地址即可,后端接口(`/wxcp/activity/signup`)不用动。
> è¯¥é¡µé¢çš„路径刻意放在 `/admin-api` ä¸‹ï¼ˆè€Œéž `/app-api`),因为前端 Vite å¼€å‘代理只转发 `/admin-api`,放 `/app-api` ä¼šå¯¼è‡´æ‰«ç åŽè¯·æ±‚打到前端静态服务返回 404。
docs/wxcp_login_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
# ä¼ä¸šå¾®ä¿¡ç™»å½•与通知 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- ç™»å½•页(`src/views/_core/authentication/login.vue`)
- ç¬¬ä¸‰æ–¹æŽˆæƒå›žè°ƒä¸­è½¬é¡µï¼ˆ`src/views/_core/authentication/social-login.vue`)
- äºŒç»´ç ç™»å½•页(`src/views/_core/authentication/qrcode-login.vue`)
## æœ¬æ¬¡å˜æ›´æ¦‚è¿°
| å˜æ›´ | å½±å“å‰ç«¯ |
|------|----------|
| ä¼ä¸šå¾®ä¿¡é¦–次扫码**自动注册**账号,不再报"未绑定账号" | æ˜¯ â€”— ç™»å½•页需新增企业微信登录入口,并去掉首次登录的绑定弹窗分支 |
| ç™»å½•成功后向用户企业微信推送**登录通知** | å¦ â€”— åŽç«¯å¼‚步发送,前端无感知 |
| æ–°å¢žä¼ä¸šå¾®ä¿¡**回调接口**(URL éªŒè¯ + æ¶ˆæ¯æŽ¥æ”¶ï¼‰ | å¦ â€”— ä¼ä¸šå¾®ä¿¡æœåŠ¡å™¨è°ƒç”¨ï¼Œä¸Žå‰ç«¯æ— å…³ |
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž | å‰ç«¯æ˜¯å¦è°ƒç”¨ |
|------|------|------|--------------|
| GET | `/admin-api/system/auth/social-auth-redirect` | èŽ·å–ä¼ä¸šå¾®ä¿¡æŽˆæƒè·³è½¬åœ°å€ | æ˜¯ |
| POST | `/admin-api/system/auth/social-login` | ä½¿ç”¨æŽˆæƒ code æ¢å–登录令牌 | æ˜¯ |
| POST | `/admin-api/system/auth/login` | è´¦å·å¯†ç ç™»å½•(可附带社交绑定参数) | æ˜¯ï¼ˆä¿ç•™ï¼‰ |
| GET | `/admin-api/wxcp/callback` | ä¼ä¸šå¾®ä¿¡å›žè°ƒ URL éªŒè¯ | å¦ |
| POST | `/admin-api/wxcp/callback` | ä¼ä¸šå¾®ä¿¡æ¶ˆæ¯/事件接收 | å¦ |
### 1. èŽ·å–ä¼ä¸šå¾®ä¿¡æŽˆæƒåœ°å€
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| type | Integer | æ˜¯ | ç¤¾äº¤ç±»åž‹ï¼Œä¼ä¸šå¾®ä¿¡å›ºå®šä¼  **30** |
| redirectUri | String | æ˜¯ | æŽˆæƒå›žè°ƒåœ°å€ï¼Œå³å‰ç«¯ social-login é¡µé¢å®Œæ•´åœ°å€ |
**响应:**
```json
{
  "code": 0,
  "data": "https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=xxx&agentid=xxx&redirect_uri=xxx&state=xxx"
}
```
前端拿到 `data` åŽç›´æŽ¥è·³è½¬è¯¥åœ°å€ï¼Œç”¨æˆ·æ‰«ç ç¡®è®¤åŽä¼ä¸šå¾®ä¿¡ä¼šå¸¦ `code` å’Œ `state` å›žè·³åˆ° `redirectUri`。
### 2. ä½¿ç”¨ code æ¢å–登录令牌
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| type | Integer | æ˜¯ | ç¤¾äº¤ç±»åž‹ï¼Œä¼ä¸šå¾®ä¿¡å›ºå®šä¼  **30** |
| code | String | æ˜¯ | ä¼ä¸šå¾®ä¿¡å›žè°ƒå¸¦å›žçš„æŽˆæƒç  |
| state | String | æ˜¯ | ä¼ä¸šå¾®ä¿¡å›žè°ƒå¸¦å›žçš„ state |
**响应(首次登录与再次登录结构一致):**
```json
{
  "code": 0,
  "data": {
    "userId": 123,
    "accessToken": "xxx",
    "refreshToken": "xxx",
    "expiresTime": "2026-09-29T12:00:00"
  }
}
```
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. ç”¨æˆ·ç‚¹å‡»ã€Œä¼ä¸šå¾®ä¿¡ç™»å½•」→ è°ƒç”¨ `social-auth-redirect`(type=30)→ æ‹¿åˆ°è·³è½¬åœ°å€ â†’ æµè§ˆå™¨è·³è½¬åˆ°ä¼ä¸šå¾®ä¿¡æ‰«ç é¡µ
2. ç”¨æˆ·æ‰«ç ç¡®è®¤ â†’ ä¼ä¸šå¾®ä¿¡å›žè·³åˆ° `redirectUri`,URL æºå¸¦ `code` å’Œ `state` â†’ ç”± social-login é¡µé¢æ‰¿æŽ¥
3. social-login é¡µé¢è¯»å– URL ä¸Šçš„ `type`、`code`、`state` â†’ è°ƒç”¨ `social-login`
4. åŽç«¯åˆ¤æ–­è¯¥ä¼ä¸šå¾®ä¿¡è´¦å·æ˜¯å¦å·²ç»‘定系统用户:
   - **已绑定** â†’ ç›´æŽ¥ç­¾å‘令牌返回
   - **未绑定(首次)** â†’ è‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç”¨æˆ·å¹¶ç»‘å®š â†’ ç­¾å‘令牌返回
5. å‰ç«¯æ‹¿åˆ° `accessToken` â†’ å†™å…¥è®¿é—®ä»¤ç‰Œå­˜å‚¨ â†’ æ‹‰å–权限信息 â†’ è·³è½¬é¦–页
**关键点:第 4 æ­¥æ— è®ºé¦–次还是再次登录,返回结构完全一致,前端不需要区分两条分支。**
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| accessToken | ç™»å½•成功后写入访问令牌存储 | åŽç»­è¯·æ±‚头携带 |
| refreshToken | ç™»å½•成功后写入访问令牌存储 | ä»¤ç‰Œè¿‡æœŸæ—¶åˆ·æ–° |
| userId | ç™»å½•成功后写入用户信息存储 | ç”¨äºŽä¸ªäººä¸­å¿ƒç­‰åœºæ™¯ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| ä¼ä¸šå¾®ä¿¡é¦–次扫码(未绑定) | è‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç”¨æˆ·ï¼Œç”¨æˆ·åä¸º `wx_` + 8 ä½éšæœºåå…­è¿›åˆ¶ï¼Œæ˜µç§°å–企业微信昵称,账号状态为启用,随机生成密码(用户无需知晓,仅用于占位) |
| ä¼ä¸šå¾®ä¿¡å†æ¬¡æ‰«ç ï¼ˆå·²ç»‘定) | ç›´æŽ¥ç™»å½•,不创建新用户 |
| éžä¼ä¸šå¾®ä¿¡çš„社交平台首次登录 | ä»è¿”回 `1_002_000_005`「未绑定账号,需要进行绑定」,需引导用户走账号密码登录并绑定 |
| è´¦å·å¯†ç ç™»å½•时携带社交参数 | èµ°åŽŸæœ‰ç»‘å®šé€»è¾‘ï¼Œç™»å½•æˆåŠŸåŽè¯¥ç¤¾äº¤è´¦å·ä¸Žè´¦å·ç»‘å®š |
| ç™»å½•成功通知 | é€šè¿‡ä¼ä¸šå¾®ä¿¡å‘送一条文本卡片消息给该用户,内容含登录时间与登录方式;用户未绑定企业微信时跳过发送,不影响登录 |
| é€šçŸ¥å‘送失败 | ä»…记录日志,**不阻塞、不回滚登录**,用户仍可正常进入系统 |
## æ³¨æ„äº‹é¡¹
- ä¼ä¸šå¾®ä¿¡æŽˆæƒç™»å½•要求**已备案域名**且备案主体与企业微信认证主体一致,本地 `localhost` è°ƒè¯•需用内网穿透并保证域名备案。
- é¦–次自动注册会跳过"注册功能已关闭"配置项(`system.user.register-enabled`),但**仍受租户账号配额限制**,配额用尽时返回 `USER_COUNT_MAX`。
- è‡ªåŠ¨æ³¨å†Œç”Ÿæˆçš„ç”¨æˆ·åä»¥ `wx_` å¼€å¤´ï¼Œä¾¿äºŽä¸Žäººå·¥åˆ›å»ºçš„账号区分;如需限制企业微信登录的用户范围,需在业务侧按用户名前缀或角色做二次管控。
- å›žè°ƒæŽ¥å£ `/admin-api/wxcp/callback` å·²åŠ å…¥å…ç™»å½•ç™½åå•ï¼Œ**不需要也不应该带前端令牌**。
- å‰ç«¯åŽŸæœ‰çš„ã€Œæœªç»‘å®š â†’ å¼¹å‡ºç»‘定账号密码弹窗」逻辑,对企业微信场景不再触发;若产品上仍希望首次登录先绑定既有账号,需要另行明确,当前实现为直接自动注册。
## éƒ¨ç½²é…ç½®æ¸…单(缺一不可)
企业微信登录要跑通,以下三处配置**必须同时存在**:
| # | é…ç½®ä½ç½® | å†…容 | ç¼ºå¤±åŽæžœ |
|---|----------|------|----------|
| 1 | `application-<profile>.yaml` â†’ `wx.cp.*` | corp-id、agent-id、secret、token、aes-key | å›žè°ƒæŽ¥å£æ— æ³•解密,URL éªŒè¯å¤±è´¥ |
| 2 | `application-<profile>.yaml` â†’ `justauth.type.WECHAT_ENTERPRISE` | client-id、client-secret、agent-id、redirect-uri | è°ƒç”¨ `social-auth-redirect` æŠ› `NullPointerException` |
| 3 | æ•°æ®åº“ `system_social_client` | social_type=30、user_type=1、client_id=CorpId、client_secret=Secret、agent_id、status=0、deleted=0 | `social-auth-redirect` æŠ¥"社交平台(30) ä¸å­˜åœ¨" |
**第 2 é¡¹å®¹æ˜“漏掉**:`AuthRequestFactory` éœ€è¦å…ˆä»Ž yaml æ‹¿åˆ°"社交平台基类配置"才能构造出对应的 `AuthRequest` å®žä¾‹ï¼Œä¹‹åŽæ‰ä¼šç”¨æ•°æ®åº“里的密钥覆盖它。所以即使密钥全都配在数据库,yaml é‡Œä¹Ÿå¿…须保留一份占位配置。
### å›žè°ƒ URL é…ç½®
在企业微信管理后台 â†’ åº”用管理 â†’ è‡ªå»ºåº”用 â†’ ã€ŒæŽ¥æ”¶æ¶ˆæ¯ã€â†’「设置API接收」:
- **URL**:`https://<已备案域名>/admin-api/wxcp/callback`
- **Token**:与 `wx.cp.token` å®Œå…¨ä¸€è‡´
- **EncodingAESKey**:与 `wx.cp.aes-key` å®Œå…¨ä¸€è‡´
点「保存」时企业微信会立即发起一次 GET éªŒè¯ï¼Œè¿”回 `echostr` æ˜Žæ–‡å³ä¸ºé€šè¿‡ã€‚
pom.xml
@@ -49,6 +49,8 @@
        <module>yudao-module-bi</module>
        <!-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å°æ¨¡å— -->
        <module>yudao-module-qcreport</module>
        <!-- ä¼ä¸šå¾®ä¿¡æ¨¡å— -->
        <module>yudao-module-wxcp</module>
        <!-- è´¢åŠ¡ç®¡ç†æ¨¡å— -->
        <module>yudao-module-finance</module>
        <!-- ä¼šå‘˜ç®¡ç†æ¨¡å— -->
yudao-dependencies/pom.xml
@@ -83,6 +83,8 @@
        <jimubi.version>2.3.2</jimubi.version>
        <bouncycastle.version>1.80</bouncycastle.version>
        <alipay-sdk-java.version>4.40.865.ALL</alipay-sdk-java.version>
        <weixin-java-cp.version>4.6.0</weixin-java-cp.version>
        <zxing.version>3.5.2</zxing.version>
    </properties>
    <dependencyManagement>
@@ -702,6 +704,25 @@
                <version>${bouncycastle.version}</version>
            </dependency>
            <!-- ä¼ä¸šå¾®ä¿¡ SDK -->
            <dependency>
                <groupId>com.github.binarywang</groupId>
                <artifactId>weixin-java-cp</artifactId>
                <version>${weixin-java-cp.version}</version>
            </dependency>
            <!-- äºŒç»´ç ç”Ÿæˆï¼ˆæ´»åŠ¨æŠ¥åç ï¼‰ -->
            <dependency>
                <groupId>com.google.zxing</groupId>
                <artifactId>core</artifactId>
                <version>${zxing.version}</version>
            </dependency>
            <dependency>
                <groupId>com.google.zxing</groupId>
                <artifactId>javase</artifactId>
                <version>${zxing.version}</version>
            </dependency>
            <!-- ç§¯æœ¨æŠ¥è¡¨-->
            <dependency>
                <groupId>org.jeecgframework.jimureport</groupId>
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -79,7 +79,10 @@
    // ===== è¯¾é¢˜ç®¡ç† =====
    RESEARCH_GUIDELINE("research_guideline", "课题指南"),
    RESEARCH_APPLICATION("research_application", "课题申报书"),
    RESEARCH_FUND_RECORD("research_fund_record", "经费拨付凭证");
    RESEARCH_FUND_RECORD("research_fund_record", "经费拨付凭证"),
    // ===== ä¼ä¸šå¾®ä¿¡æ´»åЍ =====
    WXCP_ACTIVITY_SIGNUP("wxcp_activity_signup", "企业微信活动报名");
    private final String type;
    private final String description;
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/event/SocialLoginEvent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.system.event;
import lombok.Data;
/**
 * ç¤¾äº¤ç™»å½•成功事件
 *
 * å½“用户通过社交平台(如企业微信)扫码登录成功后发布此事件,
 * ä¾›å…¶ä»–模块监听并执行后续操作(如发送通知等)
 *
 * @author è¶…级管理员
 */
@Data
public class SocialLoginEvent {
    /**
     * ç¤¾äº¤ç±»åž‹ï¼Œå¯¹åº” {@link cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum}
     */
    private final Integer socialType;
    /**
     * ç³»ç»Ÿç”¨æˆ· ID
     */
    private final Long userId;
    /**
     * ç”¨æˆ·ç™»å½•名
     */
    private final String username;
    /**
     * ç™»å½•方式描述
     */
    private final String loginType;
    public SocialLoginEvent(Integer socialType, Long userId, String username, String loginType) {
        this.socialType = socialType;
        this.userId = userId;
        this.username = username;
        this.loginType = loginType;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
@@ -17,10 +17,12 @@
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
import cn.iocoder.yudao.module.system.event.SocialLoginEvent;
import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
import cn.iocoder.yudao.module.system.service.member.MemberService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
@@ -35,10 +37,13 @@
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import java.util.UUID;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
@@ -53,8 +58,15 @@
@Slf4j
public class AdminAuthServiceImpl implements AdminAuthService {
    /**
     * ä¼ä¸šå¾®ä¿¡ç¤¾äº¤ç±»åž‹ï¼Œå¯¹åº” {@link cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum#WECHAT_ENTERPRISE}
     */
    private static final Integer WECHAT_ENTERPRISE_TYPE = 30;
    @Resource
    private AdminUserService userService;
    @Resource
    private AdminUserMapper userMapper;
    @Resource
    private LoginLogService loginLogService;
    @Resource
@@ -69,6 +81,10 @@
    private CaptchaService captchaService;
    @Resource
    private SmsCodeApi smsCodeApi;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    /**
     * éªŒè¯ç çš„开关,默认为 true
@@ -113,7 +129,15 @@
                    reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
        }
        // åˆ›å»º Token ä»¤ç‰Œï¼Œè®°å½•登录日志
        return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
        AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
        // å‘布社交登录事件(绑定新社交账号时)
        if (reqVO.getSocialType() != null) {
            eventPublisher.publishEvent(new SocialLoginEvent(
                    reqVO.getSocialType(), user.getId(), user.getUsername(), "账号密码绑定"));
        }
        return result;
    }
    @Override
@@ -169,22 +193,81 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) {
        // ä½¿ç”¨ code æŽˆæƒç ï¼Œè¿›è¡Œç™»å½•。然后,获得到绑定的用户编号
        // 1. ä½¿ç”¨ code æŽˆæƒç ï¼ŒèŽ·å–ç¤¾äº¤ç”¨æˆ·ä¿¡æ¯
        SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(),
                reqVO.getCode(), reqVO.getState());
        if (socialUser == null || socialUser.getUserId() == null) {
        if (socialUser == null) {
            throw exception(AUTH_THIRD_LOGIN_NOT_BIND);
        }
        // èŽ·å¾—ç”¨æˆ·
        AdminUserDO user = userService.getUser(socialUser.getUserId());
        if (user == null) {
            throw exception(USER_NOT_EXISTS);
        // 2. åˆ¤æ–­æ˜¯å¦å·²æœ‰ç»‘定的系统用户
        Long bindUserId = socialUser.getUserId();
        AdminUserDO user;
        if (bindUserId == null) {
            // 2.1 é¦–次登录:自动注册新用户
            user = autoRegisterUser(socialUser, reqVO.getType());
            // ç»‘定社交账号
            socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
                    reqVO.getType(), reqVO.getCode(), reqVO.getState()));
            log.info("[socialLogin][社交登录自动注册 userId={} username={} socialType={}]",
                    user.getId(), user.getUsername(), reqVO.getType());
        } else {
            // 2.2 å·²æœ‰ç»‘定:直接获取用户
            user = userService.getUser(bindUserId);
            if (user == null) {
                throw exception(USER_NOT_EXISTS);
            }
        }
        // åˆ›å»º Token ä»¤ç‰Œï¼Œè®°å½•登录日志
        return createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
        // 3. åˆ›å»º Token ä»¤ç‰Œï¼Œè®°å½•登录日志
        AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
        // 4. å‘布社交登录事件(供其他模块监听,如发送企业微信通知)
        eventPublisher.publishEvent(new SocialLoginEvent(
                reqVO.getType(), user.getId(), user.getUsername(), "社交扫码"));
        return result;
    }
    /**
     * ç¤¾äº¤ç™»å½•自动注册用户
     *
     * å½“用户首次通过社交账号(如企业微信)扫码且未绑定时,自动创建系统用户
     *
     * @param socialUser ç¤¾äº¤ç”¨æˆ·ä¿¡æ¯
     * @param socialType ç¤¾äº¤ç±»åž‹
     * @return æ–°åˆ›å»ºçš„用户
     */
    private AdminUserDO autoRegisterUser(SocialUserRespDTO socialUser, Integer socialType) {
        // 1. è‡ªåŠ¨ç”Ÿæˆç”¨æˆ·åï¼ˆwx_ + 8位随机十六进制)
        String rawUsername = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
        // 2. ç¡®ä¿ç”¨æˆ·åå”¯ä¸€ï¼ˆå¦‚果冲突则重试)
        String username = rawUsername;
        int retry = 0;
        while (userService.getUserByUsername(username) != null && retry < 5) {
            username = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
            retry++;
        }
        if (retry >= 5) {
            throw exception(USER_USERNAME_EXISTS);
        }
        // 3. ç”Ÿæˆéšæœºå¯†ç 
        String password = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16);
        // 4. åˆ›å»ºç”¨æˆ·
        AdminUserDO user = new AdminUserDO();
        user.setUsername(username);
        user.setNickname(socialUser.getNickname() != null ? socialUser.getNickname() : username);
        user.setPassword(passwordEncoder.encode(password));
        user.setStatus(CommonStatusEnum.ENABLE.getStatus());
        userMapper.insert(user);
        return user;
    }
    @VisibleForTesting
@@ -303,4 +386,4 @@
        userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
    }
}
}
yudao-module-wxcp/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>cn.iocoder.boot</groupId>
        <artifactId>yudao</artifactId>
        <version>${revision}</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>yudao-module-wxcp</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>wxcp åŒ…下,企业微信集成管理。</description>
    <dependencies>
        <!-- ç³»ç»Ÿæ¨¡å—(用于获取社交绑定信息等) -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-security</artifactId>
        </dependency>
        <!-- DB ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>
        </dependency>
        <!-- Redis -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-redis</artifactId>
        </dependency>
        <!-- ä¼ä¸šå¾®ä¿¡ SDK -->
        <dependency>
            <groupId>com.github.binarywang</groupId>
            <artifactId>weixin-java-cp</artifactId>
        </dependency>
        <!-- äºŒç»´ç ç”Ÿæˆï¼ˆæ´»åŠ¨æŠ¥åç ï¼‰ -->
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>javase</artifactId>
        </dependency>
    </dependencies>
</project>
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,220 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.*;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - ä¼ä¸šå¾®ä¿¡æ´»åЍ
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åЍ")
@RestController
@RequestMapping("/wxcp/activity")
@Validated
public class WxCpActivityController {
    @Resource
    private WxCpActivityService activityService;
    // ==================== æ´»åŠ¨ç®¡ç† ====================
    @PostMapping("/create")
    @Operation(summary = "创建活动")
    @PreAuthorize("@ss.hasPermission('wxcp:activity:create')")
    public CommonResult<Long> createActivity(@Valid @RequestBody WxCpActivitySaveReqVO createReqVO) {
        return success(activityService.createActivity(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "修改活动")
    @PreAuthorize("@ss.hasPermission('wxcp:activity:update')")
    public CommonResult<Boolean> updateActivity(@Valid @RequestBody WxCpActivitySaveReqVO updateReqVO) {
        activityService.updateActivity(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除活动")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:delete')")
    public CommonResult<Boolean> deleteActivity(@RequestParam("id") Long id) {
        activityService.deleteActivity(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得活动")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public CommonResult<WxCpActivityRespVO> getActivity(@RequestParam("id") Long id) {
        WxCpActivityDO activity = activityService.getActivity(id);
        return success(buildActivityRespVO(activity));
    }
    @GetMapping("/page")
    @Operation(summary = "获得活动分页")
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public CommonResult<PageResult<WxCpActivityRespVO>> getActivityPage(@Valid WxCpActivityPageReqVO pageReqVO) {
        PageResult<WxCpActivityDO> pageResult = activityService.getActivityPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, WxCpActivityRespVO.class, this::fillActivityNames));
    }
    @PutMapping("/publish")
    @Operation(summary = "发布活动")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:update')")
    public CommonResult<Boolean> publishActivity(@RequestParam("id") Long id) {
        activityService.publishActivity(id);
        return success(true);
    }
    @PutMapping("/finish")
    @Operation(summary = "结束活动")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:update')")
    public CommonResult<Boolean> finishActivity(@RequestParam("id") Long id) {
        activityService.finishActivity(id);
        return success(true);
    }
    @PutMapping("/cancel")
    @Operation(summary = "取消活动")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:update')")
    public CommonResult<Boolean> cancelActivity(@RequestParam("id") Long id) {
        activityService.cancelActivity(id);
        return success(true);
    }
    // ==================== æŽ¨é€ä¸ŽäºŒç»´ç  ====================
    @PostMapping("/push")
    @Operation(summary = "推送活动给企业微信成员", description = "支持覆盖推送范围与目标;不传则沿用活动上已配置的值")
    @PreAuthorize("@ss.hasPermission('wxcp:activity:push')")
    public CommonResult<WxCpPushResultVO> pushActivity(@Valid @RequestBody WxCpPushReqVO pushReqVO) {
        return success(activityService.pushActivity(pushReqVO));
    }
    @GetMapping("/qrcode")
    @Operation(summary = "获得活动报名二维码", description = "返回 PNG å›¾ç‰‡ï¼Œå¯ç›´æŽ¥ç”¨äºŽå±•示或下载打印")
    @Parameter(name = "id", description = "活动编号", required = true)
    @Parameter(name = "size", description = "边长像素,不传使用默认值")
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public ResponseEntity<byte[]> getSignupQrCode(@RequestParam("id") Long id,
                                                  @RequestParam(value = "size", required = false) Integer size) {
        byte[] png = activityService.generateSignupQrCode(id, size != null ? size : 0);
        return ResponseEntity.ok()
                .contentType(MediaType.IMAGE_PNG)
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"activity-signup-" + id + ".png\"")
                .body(png);
    }
    @GetMapping("/signup-url")
    @Operation(summary = "获得活动报名页地址", description = "便于前端自行生成二维码或复制链接")
    @Parameter(name = "id", description = "活动编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public CommonResult<String> getSignupUrl(@RequestParam("id") Long id) {
        return success(activityService.buildSignupUrl(id));
    }
    // ==================== æŠ¥åè®°å½• ====================
    @GetMapping("/signup/page")
    @Operation(summary = "获得报名记录分页")
    @PreAuthorize("@ss.hasPermission('wxcp:signup:query')")
    public CommonResult<PageResult<WxCpSignupRespVO>> getSignupPage(@Valid WxCpSignupPageReqVO pageReqVO) {
        PageResult<WxCpActivitySignupDO> pageResult = activityService.getSignupPage(pageReqVO);
        PageResult<WxCpSignupRespVO> result = BeanUtils.toBean(pageResult, WxCpSignupRespVO.class);
        // è¡¥ä¸Šæ´»åŠ¨æ ‡é¢˜ä¸ŽçŠ¶æ€åï¼Œé¿å…å‰ç«¯ä¸ºä¸€è¡Œè®°å½•å†æŸ¥ä¸€æ¬¡æ´»åŠ¨
        result.getList().forEach(vo -> {
            WxCpActivityDO activity = vo.getActivityId() != null
                    ? activityService.getActivity(vo.getActivityId()) : null;
            if (activity != null) {
                vo.setActivityTitle(activity.getTitle());
            }
            WxCpSignupStatusEnum status = WxCpSignupStatusEnum.valueOf(vo.getStatus());
            vo.setStatusName(status != null ? status.getName() : null);
        });
        return success(result);
    }
    @PutMapping("/signup/cancel")
    @Operation(summary = "取消报名")
    @Parameter(name = "id", description = "报名记录编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:signup:update')")
    public CommonResult<Boolean> cancelSignup(@RequestParam("id") Long id) {
        activityService.cancelSignup(id);
        return success(true);
    }
    @PutMapping("/signup/check-in")
    @Operation(summary = "报名签到")
    @Parameter(name = "id", description = "报名记录编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:signup:update')")
    public CommonResult<Boolean> checkInSignup(@RequestParam("id") Long id) {
        activityService.checkInSignup(id);
        return success(true);
    }
    @GetMapping("/stats")
    @Operation(summary = "获得活动报名统计", description = "含总数、状态分布、按部门分布、按日期趋势、签到率")
    @Parameter(name = "id", description = "活动编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public CommonResult<WxCpActivityStatsRespVO> getActivityStats(@RequestParam("id") Long id) {
        return success(activityService.getActivityStats(id));
    }
    @GetMapping("/signup/attachments")
    @Operation(summary = "获得某条报名记录上传的图片/附件")
    @Parameter(name = "signupId", description = "报名记录编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:signup:query')")
    public CommonResult<List<SystemStorageBlobRespVO>> getSignupAttachments(@RequestParam("signupId") Long signupId) {
        return success(activityService.getSignupAttachments(signupId));
    }
    // ==================== è½¬æ¢è¾…助 ====================
    private WxCpActivityRespVO buildActivityRespVO(WxCpActivityDO activity) {
        if (activity == null) {
            return null;
        }
        WxCpActivityRespVO vo = BeanUtils.toBean(activity, WxCpActivityRespVO.class);
        fillActivityNames(vo);
        return vo;
    }
    private void fillActivityNames(WxCpActivityRespVO vo) {
        WxCpActivityStatusEnum status = WxCpActivityStatusEnum.valueOf(vo.getStatus());
        vo.setStatusName(status != null ? status.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,252 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpSignupPublicReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ - æŠ¥åè½åœ°é¡µä¸ŽæŠ¥åæŽ¥å£ï¼ˆå…ç™»å½•)
 *
 * è¯´æ˜Žï¼šè¿™ä¸¤ä¸ªæŽ¥å£åˆ»æ„æ”¾åœ¨ controller.admin åŒ…下,从而带上 /admin-api å‰ç¼€ã€‚
 * åŽŸå› æ˜¯å‰ç«¯ Vite çš„开发代理只转发 /admin-api,若放到 /app-api,
 * æ‰«ç åŽè¯·æ±‚会打到前端静态服务返回 404,报名页打不开。
 *
 * @author è¶…级管理员
 */
@Tag(name = "用户 App - ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥å")
@RestController
@RequestMapping("/wxcp/activity")
@Validated
@Slf4j
public class WxCpActivityPublicController {
    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    @Resource
    private WxCpActivityService activityService;
    @GetMapping(value = "/signup-page", produces = MediaType.TEXT_HTML_VALUE)
    @PermitAll
    @Operation(summary = "活动报名页(HTML)", description = "二维码指向此页面,企业微信成员扫码后打开")
    @Parameter(name = "id", description = "活动编号", required = true)
    public String signupPage(@RequestParam("id") Long id,
                             @RequestParam(value = "wxUserId", required = false) String wxUserId) {
        WxCpActivityDO activity = activityService.getActivity(id);
        if (activity == null) {
            return renderMessagePage("活动不存在", "活动可能已被删除,请联系活动发起人确认。");
        }
        if (!isSignupOpen(activity)) {
            return renderMessagePage("报名已结束",
                    "该活动当前不可报名(可能未发布、已结束或不在报名时间内)。");
        }
        return renderSignupPage(activity, wxUserId);
    }
    @PostMapping("/signup")
    @PermitAll
    @Operation(summary = "提交活动报名", description = "免登录;有企业微信身份时按成员账号去重,否则按手机号去重")
    public CommonResult<Long> signup(@Valid @RequestBody WxCpSignupPublicReqVO reqVO) {
        return success(activityService.createSignup(reqVO));
    }
    /**
     * æŠ¥åæ˜¯å¦å¼€æ”¾ï¼šæ´»åŠ¨éœ€ä¸ºå·²å‘å¸ƒï¼Œä¸”å¤„äºŽæŠ¥åæ—¶é—´çª—å£å†…
     */
    private boolean isSignupOpen(WxCpActivityDO activity) {
        if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) {
            return false;
        }
        LocalDateTime now = LocalDateTime.now();
        if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) {
            return false;
        }
        return activity.getSignupEndTime() == null || !now.isAfter(activity.getSignupEndTime());
    }
    private String renderSignupPage(WxCpActivityDO activity, String wxUserId) {
        StringBuilder sb = new StringBuilder(2048);
        sb.append("<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">")
                .append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1\">")
                .append("<title>").append(esc(activity.getTitle())).append("</title>")
                .append("<style>")
                .append("*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}")
                .append("body{margin:0;padding:16px;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;")
                .append("background:#f5f6f8;color:#1f2329;font-size:15px;line-height:1.6}")
                .append(".card{background:#fff;border-radius:12px;padding:18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}")
                .append("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}")
                .append(".meta{color:#646a73;font-size:14px}")
                .append(".meta div{margin-bottom:6px;display:flex}")
                .append(".meta b{color:#1f2329;font-weight:500;flex:0 0 72px}")
                .append(".meta span{flex:1;word-break:break-all}")
                .append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}")
                .append("label{display:block;font-size:14px;color:#646a73;margin:14px 0 6px}")
                .append("input,textarea{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}")
                .append("input:focus,textarea:focus{border-color:#3370ff}")
                .append("textarea{resize:vertical;min-height:84px}")
                .append(".file-row{margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}")
                .append(".file-btn{display:inline-block;padding:8px 14px;border:1px dashed #c9cdd4;border-radius:8px;color:#3370ff;font-size:14px;background:#fff}")
                .append(".file-list{margin-top:8px;font-size:13px;color:#646a73;word-break:break-all}")
                .append(".file-list div{padding:3px 0}")
                .append("button{width:100%;margin-top:22px;padding:13px;border:0;border-radius:8px;background:#3370ff;color:#fff;font-size:16px;font-weight:500}")
                .append("button:disabled{background:#a8bdf5}")
                .append(".tip{margin-top:12px;font-size:12px;color:#8f959e;text-align:center}")
                .append(".toast{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.78);color:#fff;")
                .append("padding:11px 20px;border-radius:8px;font-size:14px;display:none;max-width:80%;text-align:center;z-index:99}")
                .append("</style></head><body>");
        // æ´»åŠ¨ä¿¡æ¯
        sb.append("<div class=\"card\"><h1>").append(esc(activity.getTitle())).append("</h1>")
                .append("<div class=\"meta\">");
        String timeText = formatTimeRange(activity.getStartTime(), activity.getEndTime());
        if (StrUtil.isNotBlank(timeText)) {
            sb.append("<div><b>活动时间</b><span>").append(esc(timeText)).append("</span></div>");
        }
        if (StrUtil.isNotBlank(activity.getLocation())) {
            sb.append("<div><b>活动地点</b><span>").append(esc(activity.getLocation())).append("</span></div>");
        }
        String signupText = formatTimeRange(activity.getSignupStartTime(), activity.getSignupEndTime());
        if (StrUtil.isNotBlank(signupText)) {
            sb.append("<div><b>报名时间</b><span>").append(esc(signupText)).append("</span></div>");
        }
        sb.append("<div><b>已报名</b><span>").append(activity.getSignupCount() != null ? activity.getSignupCount() : 0);
        if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0) {
            sb.append(" / ").append(activity.getSignupLimit()).append(" äºº");
        } else {
            sb.append(" äººï¼ˆä¸é™åé¢ï¼‰");
        }
        sb.append("</span></div></div>");
        if (StrUtil.isNotBlank(activity.getContent())) {
            sb.append("<div class=\"desc\">").append(esc(activity.getContent())).append("</div>");
        }
        sb.append("</div>");
        // æŠ¥åè¡¨å•
        sb.append("<div class=\"card\">")
                .append("<label>姓名 <span style=\"color:#f53f3f\">*</span></label>")
                .append("<input id=\"name\" type=\"text\" placeholder=\"请输入姓名\" maxlength=\"64\" autocomplete=\"name\">")
                .append("<label>手机号 <span style=\"color:#f53f3f\">*</span></label>")
                .append("<input id=\"mobile\" type=\"tel\" inputmode=\"numeric\" placeholder=\"请输入 11 ä½æ‰‹æœºå·\" maxlength=\"11\" autocomplete=\"tel\">")
                .append("<label>参与说明</label>")
                .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>")
                .append("<label>图片 / é™„ä»¶</label>")
                .append("<div class=\"file-row\">")
                .append("<label class=\"file-btn\" for=\"files\">+ é€‰æ‹©å›¾ç‰‡æˆ–文件</label>")
                .append("<input id=\"files\" type=\"file\" multiple accept=\"image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt\" style=\"display:none\">")
                .append("</div><div class=\"file-list\" id=\"fileList\"></div>")
                .append("<button id=\"submit\">提交报名</button>")
                .append("<div class=\"tip\">提交后如需修改,请联系活动发起人</div>")
                .append("</div><div class=\"toast\" id=\"toast\"></div>");
        // æäº¤é€»è¾‘:先把文件传到 system çš„公共上传接口拿到 blobId,再带上 blobId æäº¤æŠ¥å
        sb.append("<script>(function(){")
                .append("var AID=").append(activity.getId()).append(";")
                .append("var WXUID=").append(wxUserId == null ? "null" : "\"" + esc(jsStr(wxUserId)) + "\"").append(";")
                .append("var btn=document.getElementById('submit'),toast=document.getElementById('toast');")
                .append("var fileInput=document.getElementById('files'),fileList=document.getElementById('fileList');")
                .append("function tip(m){toast.textContent=m;toast.style.display='block';setTimeout(function(){toast.style.display='none'},2200)}")
                .append("function reset(){btn.disabled=false;btn.textContent='提交报名'}")
                .append("fileInput.addEventListener('change',function(){")
                .append("var fs=fileInput.files,h='';")
                .append("for(var i=0;i<fs.length;i++){h+='<div>· '+fs[i].name+'</div>'}")
                .append("fileList.innerHTML=h})")
                .append("btn.addEventListener('click',function(){")
                .append("var n=document.getElementById('name').value.trim();")
                .append("var m=document.getElementById('mobile').value.trim();")
                .append("var c=document.getElementById('content').value.trim();")
                .append("if(!n){tip('请填写姓名');return}")
                .append("if(!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确');return}")
                .append("btn.disabled=true;")
                .append("var fs=fileInput.files;")
                .append("var upload=Promise.resolve([]);")
                .append("if(fs.length>0){")
                .append("btn.textContent='上传附件中...';")
                .append("var fd=new FormData();")
                .append("for(var i=0;i<fs.length;i++){fd.append('files',fs[i])}")
                .append("upload=fetch('/admin-api/system/storage-blob/public-upload',{method:'POST',body:fd})")
                .append(".then(function(r){return r.json()})")
                .append(".then(function(r){")
                .append("if(r.code!==0){throw new Error(r.msg||'附件上传失败')}")
                .append("return (r.data||[]).map(function(b){return b.id})})}")
                .append("upload.then(function(blobIds){")
                .append("btn.textContent='提交中...';")
                .append("var body={activityId:AID,name:n,mobile:m};")
                .append("if(c){body.content=c}")
                .append("if(WXUID){body.wxUserId=WXUID}")
                .append("if(blobIds.length>0){body.blobIds=blobIds}")
                .append("return fetch('signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})")
                .append(".then(function(r){return r.json()})")
                .append(".then(function(r){")
                .append("if(r.code===0){tip('报名成功');btn.textContent='已报名';}")
                .append("else{tip(r.msg||'报名失败');reset()}})}")
                .append(".catch(function(e){tip((e&&e.message)||'网络异常,请稍后重试');reset()})")
                .append("})})();</script></body></html>");
        return sb.toString();
    }
    private String renderMessagePage(String title, String desc) {
        return "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">"
                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
                + "<title>" + esc(title) + "</title><style>"
                + "body{margin:0;padding:60px 24px;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;"
                + "background:#f5f6f8;color:#1f2329;text-align:center}"
                + "h1{font-size:18px;font-weight:600;margin:0 0 12px}"
                + "p{color:#646a73;font-size:14px;line-height:1.7;margin:0}"
                + "</style></head><body><h1>" + esc(title) + "</h1><p>" + esc(desc) + "</p></body></html>";
    }
    private String formatTimeRange(LocalDateTime start, LocalDateTime end) {
        if (start == null && end == null) {
            return "";
        }
        if (start == null) {
            return "截止 " + end.format(TIME_FMT);
        }
        if (end == null) {
            return start.format(TIME_FMT) + " èµ·";
        }
        return start.format(TIME_FMT) + " ~ " + end.format(TIME_FMT);
    }
    /**
     * HTML è½¬ä¹‰ï¼Œé¿å…æ´»åŠ¨æ ‡é¢˜/内容里的特殊字符破坏页面或造成 XSS
     */
    private String esc(String raw) {
        if (raw == null) {
            return "";
        }
        return raw.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
    /**
     * JS å­—符串字面量转义(在 esc åŸºç¡€ä¸Šå†å¤„理换行与反斜杠)
     */
    private String jsStr(String raw) {
        return raw.replace("\\", "\\\\")
                .replace("\n", "\\n")
                .replace("\r", "\\r");
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åЍ åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class WxCpActivityPageReqVO extends PageParam {
    @Schema(description = "活动标题,模糊匹配", example = "团建")
    private String title;
    @Schema(description = "状态", example = "1")
    @InEnum(WxCpActivityStatusEnum.class)
    private Integer status;
    @Schema(description = "推送状态:0 æœªæŽ¨é€ã€1 å·²æŽ¨é€", example = "1")
    private Integer pushStatus;
    @Schema(description = "活动开始时间范围")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] startTime;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åЍ Response VO")
@Data
public class WxCpActivityRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "活动标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026 å¹´æ˜¥å­£å›¢å»º")
    private String title;
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "封面图地址")
    private String coverUrl;
    @Schema(description = "活动地点", example = "公司大会议室")
    private String location;
    @Schema(description = "活动开始时间")
    private LocalDateTime startTime;
    @Schema(description = "活动结束时间")
    private LocalDateTime endTime;
    @Schema(description = "报名开始时间")
    private LocalDateTime signupStartTime;
    @Schema(description = "报名结束时间")
    private LocalDateTime signupEndTime;
    @Schema(description = "报名人数上限,0 è¡¨ç¤ºä¸é™", example = "50")
    private Integer signupLimit;
    @Schema(description = "已报名人数", example = "12")
    private Integer signupCount;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
    @Schema(description = "状态名", example = "已发布")
    private String statusName;
    @Schema(description = "推送范围", example = "0")
    private Integer pushScope;
    @Schema(description = "推送范围名", example = "全员")
    private String pushScopeName;
    @Schema(description = "推送目标")
    private String pushTarget;
    @Schema(description = "推送状态:0 æœªæŽ¨é€ã€1 å·²æŽ¨é€", example = "1")
    private Integer pushStatus;
    @Schema(description = "最近推送时间")
    private LocalDateTime pushTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    private LocalDateTime createTime;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åЍ åˆ›å»º/修改 Request VO")
@Data
public class WxCpActivitySaveReqVO {
    @Schema(description = "编号", example = "1024")
    private Long id;
    @Schema(description = "活动标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026 å¹´æ˜¥å­£å›¢å»º")
    @NotEmpty(message = "活动标题不能为空")
    @Size(max = 128, message = "活动标题长度不能超过 128 ä¸ªå­—符")
    private String title;
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "封面图地址")
    @Size(max = 512, message = "封面图地址长度不能超过 512 ä¸ªå­—符")
    private String coverUrl;
    @Schema(description = "活动地点", example = "公司大会议室")
    @Size(max = 255, message = "活动地点长度不能超过 255 ä¸ªå­—符")
    private String location;
    @Schema(description = "活动开始时间")
    private LocalDateTime startTime;
    @Schema(description = "活动结束时间")
    private LocalDateTime endTime;
    @Schema(description = "报名开始时间")
    private LocalDateTime signupStartTime;
    @Schema(description = "报名结束时间")
    private LocalDateTime signupEndTime;
    @Schema(description = "报名人数上限,0 è¡¨ç¤ºä¸é™", example = "50")
    @NotNull(message = "报名人数上限不能为空,不限请填 0")
    private Integer signupLimit;
    @Schema(description = "推送范围(选填,不传默认全员);推送时仍可在推送弹窗里重新选择", example = "0")
    @InEnum(WxCpPushScopeEnum.class)
    private Integer pushScope;
    @Schema(description = "推送目标:部门ID或成员账号,多个用逗号分隔", example = "1,2 æˆ– LiuYi,ZhangSan")
    @Size(max = 1024, message = "推送目标长度不能超过 1024 ä¸ªå­—符")
    private String pushTarget;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注长度不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityStatsRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - æ´»åŠ¨æŠ¥åç»Ÿè®¡ Response VO")
@Data
public class WxCpActivityStatsRespVO {
    @Schema(description = "活动编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long activityId;
    @Schema(description = "活动标题", example = "2026 å¹´æ˜¥å­£å›¢å»º")
    private String activityTitle;
    @Schema(description = "报名上限,0 è¡¨ç¤ºä¸é™", example = "50")
    private Integer signupLimit;
    @Schema(description = "有效报名数(不含已取消)", example = "12")
    private Integer totalSignup;
    @Schema(description = "已报名(未签到)", example = "8")
    private Integer signedUpCount;
    @Schema(description = "已签到", example = "3")
    private Integer checkedInCount;
    @Schema(description = "已取消", example = "1")
    private Integer canceledCount;
    @Schema(description = "剩余名额;不限名额时为 null", example = "38")
    private Integer remaining;
    @Schema(description = "报名进度百分比(0-100);不限名额时为 null", example = "24")
    private Integer signupPercent;
    @Schema(description = "签到率百分比(0-100),按有效报名数计算", example = "25")
    private Integer checkInPercent;
    @Schema(description = "按部门分布")
    private List<NameCount> deptStats;
    @Schema(description = "按报名日期趋势")
    private List<NameCount> dateStats;
    @Schema(description = "按成员账号来源分布(企业微信身份 / æ™®é€šå¡«å†™ï¼‰")
    private List<NameCount> sourceStats;
    @Schema(description = "名称-数量统计项")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class NameCount {
        @Schema(description = "名称", example = "技术部")
        private String name;
        @Schema(description = "数量", example = "5")
        private Integer count;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥å åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class WxCpSignupPageReqVO extends PageParam {
    @Schema(description = "活动编号", example = "1024")
    private Long activityId;
    @Schema(description = "姓名,模糊匹配", example = "刘")
    private String name;
    @Schema(description = "手机号,模糊匹配", example = "138")
    private String mobile;
    @Schema(description = "状态", example = "0")
    @InEnum(WxCpSignupStatusEnum.class)
    private Integer status;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "用户 App - æ´»åŠ¨æŠ¥åæäº¤ Request VO(免登录)")
@Data
public class WxCpSignupPublicReqVO {
    @Schema(description = "活动编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "活动编号不能为空")
    private Long activityId;
    @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "刘意")
    @NotEmpty(message = "姓名不能为空")
    @Size(max = 64, message = "姓名长度不能超过 64 ä¸ªå­—符")
    private String name;
    @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000")
    @NotEmpty(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String mobile;
    @Schema(description = "企业微信成员账号;从企业微信内打开时由前端自动带上,普通浏览器报名可不传")
    @Size(max = 128, message = "企业微信成员账号长度不能超过 128 ä¸ªå­—符")
    private String wxUserId;
    @Schema(description = "部门名称")
    @Size(max = 255, message = "部门名称长度不能超过 255 ä¸ªå­—符")
    private String deptName;
    @Schema(description = "参与说明(成员填写的文字内容)")
    private String content;
    @Schema(description = "附件文件编号列表", example = "[1,2]")
    private List<Long> blobIds;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注长度不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥å Response VO")
@Data
public class WxCpSignupRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
    private Long id;
    @Schema(description = "活动编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long activityId;
    @Schema(description = "活动标题", example = "2026 å¹´æ˜¥å­£å›¢å»º")
    private String activityTitle;
    @Schema(description = "企业微信成员账号")
    private String wxUserId;
    @Schema(description = "姓名", example = "刘意")
    private String name;
    @Schema(description = "手机号", example = "13800138000")
    private String mobile;
    @Schema(description = "部门名称")
    private String deptName;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    private Integer status;
    @Schema(description = "状态名", example = "已报名")
    private String statusName;
    @Schema(description = "报名时间")
    private LocalDateTime signupTime;
    @Schema(description = "签到时间")
    private LocalDateTime checkInTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/callback/WxCpCallbackController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.callback;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.util.crypto.SHA1;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.message.WxCpXmlMessage;
import me.chanjar.weixin.cp.bean.message.WxCpXmlOutMessage;
import me.chanjar.weixin.cp.bean.message.WxCpXmlOutTextMessage;
import me.chanjar.weixin.cp.util.crypto.WxCpCryptUtil;
import org.springframework.web.bind.annotation.*;
/**
 * ä¼ä¸šå¾®ä¿¡å›žè°ƒ Controller
 *
 * ç”¨äºŽæŽ¥æ”¶ä¼ä¸šå¾®ä¿¡çš„回调通知,包括 URL éªŒè¯å’Œæ¶ˆæ¯/事件推送
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - ä¼ä¸šå¾®ä¿¡å›žè°ƒ")
@RestController
@RequestMapping("/wxcp/callback")
@Slf4j
public class WxCpCallbackController {
    @Resource
    private WxCpService wxCpService;
    /**
     * GET è¯·æ±‚ â€” ä¼ä¸šå¾®ä¿¡å›žè°ƒ URL éªŒè¯
     *
     * ä¼ä¸šå¾®ä¿¡ç®¡ç†åŽå°é…ç½®"接收消息"URL æ—¶ï¼Œä¼šå‘送 GET è¯·æ±‚验证 URL æœ‰æ•ˆæ€§
     * éœ€è¦åœ¨éªŒè¯é€šè¿‡åŽåŽŸæ ·è¿”å›ž echostr è§£å¯†åŽçš„内容
     */
    @GetMapping(produces = "text/plain;charset=utf-8")
    @Operation(summary = "企业微信回调 URL éªŒè¯")
    @Parameters({
            @Parameter(name = "msg_signature", description = "企业微信加密签名"),
            @Parameter(name = "timestamp", description = "时间戳"),
            @Parameter(name = "nonce", description = "随机数"),
            @Parameter(name = "echostr", description = "加密的字符串,需要解密回显")
    })
    public String verifyUrl(@RequestParam("msg_signature") String msgSignature,
                            @RequestParam("timestamp") String timestamp,
                            @RequestParam("nonce") String nonce,
                            @RequestParam("echostr") String echostr) {
        log.info("[verifyUrl][收到企业微信 URL éªŒè¯è¯·æ±‚ msg_signature={} timestamp={} nonce={} echostr={}]",
                msgSignature, timestamp, nonce, echostr);
        try {
            // 1. éªŒè¯ç­¾å
            // æ³¨æ„ï¼šSHA1.gen è¿”回小写十六进制,企业微信下发的 msg_signature ä¹Ÿæ˜¯å°å†™ï¼Œ
            // å› æ­¤å¿…须用 equalsIgnoreCase æ¯”较;若把传入值转大写再与计算值比较,会永远不相等
            String signature = SHA1.gen(wxCpService.getWxCpConfigStorage().getToken(), timestamp, nonce, echostr);
            if (!signature.equalsIgnoreCase(msgSignature)) {
                log.error("[verifyUrl][签名验证失败,计算签名={},传入签名={}]", signature, msgSignature);
                return "error: signature verification failed";
            }
            // 2. è§£å¯† echostr å¹¶è¿”回(使用 WxCpCryptUtil)
            WxCpCryptUtil cryptUtil = new WxCpCryptUtil(wxCpService.getWxCpConfigStorage());
            String plainText = cryptUtil.decrypt(echostr);
            log.info("[verifyUrl][URL éªŒè¯æˆåŠŸ]");
            return plainText;
        } catch (Exception e) {
            log.error("[verifyUrl][URL éªŒè¯å¤±è´¥]", e);
            return "error: " + e.getMessage();
        }
    }
    /**
     * POST è¯·æ±‚ â€” æŽ¥æ”¶ä¼ä¸šå¾®ä¿¡æŽ¨é€çš„æ¶ˆæ¯å’Œäº‹ä»¶
     *
     * åŒ…括:文本消息、图片消息、进入应用事件、扫码事件等
     */
    @PostMapping(produces = "application/xml;charset=UTF-8")
    @Operation(summary = "接收企业微信消息/事件推送")
    @Parameters({
            @Parameter(name = "msg_signature", description = "企业微信加密签名"),
            @Parameter(name = "timestamp", description = "时间戳"),
            @Parameter(name = "nonce", description = "随机数"),
    })
    public String handleMessage(HttpServletRequest request,
                                @RequestParam("msg_signature") String msgSignature,
                                @RequestParam("timestamp") String timestamp,
                                @RequestParam("nonce") String nonce,
                                @RequestBody String requestBody) {
        log.info("[handleMessage][收到企业微信消息推送 msg_signature={} timestamp={} nonce={} body={}]",
                msgSignature, timestamp, nonce, requestBody);
        try {
            // 1. è§£å¯†æ¶ˆæ¯
            WxCpXmlMessage inMessage = WxCpXmlMessage.fromEncryptedXml(
                    requestBody, wxCpService.getWxCpConfigStorage(), timestamp, nonce, msgSignature);
            if (inMessage == null) {
                log.warn("[handleMessage][消息解密失败,返回空对象]");
                return "error: decrypt failed";
            }
            log.info("[handleMessage][解密成功 msgType={} event={} fromUser={}]",
                    inMessage.getMsgType(), inMessage.getEvent(), inMessage.getFromUserName());
            // 2. æ ¹æ®æ¶ˆæ¯ç±»åž‹å¤„理
            WxCpXmlOutMessage outMessage = route(inMessage);
            // 3. è¿”回响应
            if (outMessage != null) {
                String response = outMessage.toEncryptedXml(wxCpService.getWxCpConfigStorage());
                log.info("[handleMessage][响应消息: {}]", response);
                return response;
            }
            return "success";
        } catch (Exception e) {
            log.error("[handleMessage][处理消息异常]", e);
            return "success"; // ä¼ä¸šå¾®ä¿¡è¦æ±‚无论如何都要返回 success
        }
    }
    /**
     * è·¯ç”±æ¶ˆæ¯åˆ°å¯¹åº”的处理器
     *
     * @param inMessage è§£å¯†åŽçš„æ¶ˆæ¯
     * @return å“åº”消息(可为 null)
     */
    private WxCpXmlOutMessage route(WxCpXmlMessage inMessage) {
        // äº‹ä»¶ç±»åž‹
        if ("event".equals(inMessage.getMsgType())) {
            String event = inMessage.getEvent();
            if ("enter_agent".equals(event)) {
                log.info("[route][进入应用] ä»Žç”¨æˆ· {}", inMessage.getFromUserName());
                return buildTextReply(inMessage, "欢迎进入应用!");
            } else if ("subscribe".equals(event)) {
                log.info("[route][用户关注]");
                return buildTextReply(inMessage, "欢迎关注!");
            } else if ("change_contact".equals(event)) {
                log.info("[route][通讯录变更] å˜æ›´ç±»åž‹={}", inMessage.getChangeType());
                return null;
            } else {
                log.info("[route][未处理事件类型={}]", event);
                return null;
            }
        }
        // æ–‡æœ¬æ¶ˆæ¯
        if ("text".equals(inMessage.getMsgType())) {
            log.info("[route][收到文本消息 from={} content={}]",
                    inMessage.getFromUserName(), inMessage.getContent());
            return buildTextReply(inMessage, "已收到您的消息");
        }
        log.info("[route][未处理消息类型={}]", inMessage.getMsgType());
        return null;
    }
    private WxCpXmlOutMessage buildTextReply(WxCpXmlMessage inMessage, String content) {
        WxCpXmlOutTextMessage out = new WxCpXmlOutTextMessage();
        out.setContent(content);
        out.setCreateTime(System.currentTimeMillis() / 1000);
        out.setFromUserName(inMessage.getToUserName());
        out.setToUserName(inMessage.getFromUserName());
        out.setMsgType("text");
        return out;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/WxCpMemberController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.member;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpDepartmentRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpMemberRespVO;
import cn.iocoder.yudao.module.wxcp.service.member.WxCpMemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - ä¼ä¸šå¾®ä¿¡é€šè®¯å½•(成员与部门)
 *
 * æ•°æ®å®žæ—¶å–自企业微信,可用于推送时挑选目标成员/部门
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šè®¯å½•")
@RestController
@RequestMapping("/wxcp/member")
@Validated
public class WxCpMemberController {
    @Resource
    private WxCpMemberService memberService;
    @GetMapping("/department/tree")
    @Operation(summary = "获得部门树")
    @PreAuthorize("@ss.hasPermission('wxcp:member:query')")
    public CommonResult<List<WxCpDepartmentRespVO>> getDepartmentTree() {
        return success(memberService.getDepartmentTree());
    }
    @GetMapping("/department/list")
    @Operation(summary = "获得部门平铺列表", description = "便于做下拉选择,无需自行拍平树")
    @PreAuthorize("@ss.hasPermission('wxcp:member:query')")
    public CommonResult<List<WxCpDepartmentRespVO>> getDepartmentList() {
        return success(memberService.getDepartmentList());
    }
    @GetMapping("/list")
    @Operation(summary = "获得成员列表")
    @Parameter(name = "departmentId", description = "部门ID,不传取根部门")
    @Parameter(name = "fetchChild", description = "是否包含子部门成员,默认 true")
    @PreAuthorize("@ss.hasPermission('wxcp:member:query')")
    public CommonResult<List<WxCpMemberRespVO>> getMemberList(
            @RequestParam(value = "departmentId", required = false) Long departmentId,
            @RequestParam(value = "fetchChild", required = false) Boolean fetchChild) {
        return success(memberService.getMemberList(departmentId, fetchChild));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/vo/WxCpDepartmentRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.member.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡éƒ¨é—¨ Response VO")
@Data
public class WxCpDepartmentRespVO {
    @Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Long id;
    @Schema(description = "部门名称", example = "技术部")
    private String name;
    @Schema(description = "父部门ID", example = "0")
    private Long parentId;
    @Schema(description = "在父部门中的次序值", example = "1")
    private Long order;
    @Schema(description = "子部门")
    private List<WxCpDepartmentRespVO> children = new ArrayList<>();
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/member/vo/WxCpMemberRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.member.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æˆå‘˜ Response VO")
@Data
public class WxCpMemberRespVO {
    @Schema(description = "成员账号(UserId),可作为推送目标", requiredMode = Schema.RequiredMode.REQUIRED, example = "LiuYi")
    private String userId;
    @Schema(description = "成员名称", example = "刘意")
    private String name;
    @Schema(description = "手机号", example = "13800138000")
    private String mobile;
    @Schema(description = "邮箱")
    private String email;
    @Schema(description = "头像地址")
    private String avatar;
    @Schema(description = "职位", example = "开发工程师")
    private String position;
    @Schema(description = "性别:0未定义 1男 2女", example = "1")
    private String gender;
    @Schema(description = "是否启用:1已启用 2已禁用 4未激活 5退出企业", example = "1")
    private Integer status;
    @Schema(description = "所属部门ID列表", example = "[1,2]")
    private List<Long> departmentIds;
    @Schema(description = "所属部门名称,便于列表直接展示", example = "技术部")
    private String departmentNames;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.service.notice.WxCpNoticeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - ä¼ä¸šå¾®ä¿¡é€šçŸ¥
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥")
@RestController
@RequestMapping("/wxcp/notice")
@Validated
public class WxCpNoticeController {
    @Resource
    private WxCpNoticeService noticeService;
    @PostMapping("/create")
    @Operation(summary = "创建通知")
    @PreAuthorize("@ss.hasPermission('wxcp:notice:create')")
    public CommonResult<Long> createNotice(@Valid @RequestBody WxCpNoticeSaveReqVO createReqVO) {
        return success(noticeService.createNotice(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "修改通知")
    @PreAuthorize("@ss.hasPermission('wxcp:notice:update')")
    public CommonResult<Boolean> updateNotice(@Valid @RequestBody WxCpNoticeSaveReqVO updateReqVO) {
        noticeService.updateNotice(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除通知")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:notice:delete')")
    public CommonResult<Boolean> deleteNotice(@RequestParam("id") Long id) {
        noticeService.deleteNotice(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得通知")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:notice:query')")
    public CommonResult<WxCpNoticeRespVO> getNotice(@RequestParam("id") Long id) {
        WxCpNoticeDO notice = noticeService.getNotice(id);
        return success(buildRespVO(notice));
    }
    @GetMapping("/page")
    @Operation(summary = "获得通知分页")
    @PreAuthorize("@ss.hasPermission('wxcp:notice:query')")
    public CommonResult<PageResult<WxCpNoticeRespVO>> getNoticePage(@Valid WxCpNoticePageReqVO pageReqVO) {
        PageResult<WxCpNoticeDO> pageResult = noticeService.getNoticePage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, WxCpNoticeRespVO.class, this::fillNames));
    }
    @PutMapping("/publish")
    @Operation(summary = "发布通知")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:notice:update')")
    public CommonResult<Boolean> publishNotice(@RequestParam("id") Long id) {
        noticeService.publishNotice(id);
        return success(true);
    }
    @PutMapping("/revoke")
    @Operation(summary = "撤回通知")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('wxcp:notice:update')")
    public CommonResult<Boolean> revokeNotice(@RequestParam("id") Long id) {
        noticeService.revokeNotice(id);
        return success(true);
    }
    @PostMapping("/push")
    @Operation(summary = "推送通知给企业微信成员", description = "支持覆盖推送范围与目标;不传则沿用通知上已配置的值")
    @PreAuthorize("@ss.hasPermission('wxcp:notice:push')")
    public CommonResult<WxCpPushResultVO> pushNotice(@Valid @RequestBody WxCpPushReqVO pushReqVO) {
        return success(noticeService.pushNotice(pushReqVO));
    }
    // ==================== è½¬æ¢è¾…助 ====================
    private WxCpNoticeRespVO buildRespVO(WxCpNoticeDO notice) {
        if (notice == null) {
            return null;
        }
        WxCpNoticeRespVO vo = BeanUtils.toBean(notice, WxCpNoticeRespVO.class);
        fillNames(vo);
        return vo;
    }
    private void fillNames(WxCpNoticeRespVO vo) {
        WxCpNoticeStatusEnum status = WxCpNoticeStatusEnum.valueOf(vo.getStatus());
        vo.setStatusName(status != null ? status.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥ åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class WxCpNoticePageReqVO extends PageParam {
    @Schema(description = "通知标题,模糊匹配", example = "放假")
    private String title;
    @Schema(description = "状态", example = "1")
    @InEnum(WxCpNoticeStatusEnum.class)
    private Integer status;
    @Schema(description = "推送状态:0 æœªæŽ¨é€ã€1 å·²æŽ¨é€", example = "1")
    private Integer pushStatus;
    @Schema(description = "创建时间范围")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] createTime;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Response VO")
@Data
public class WxCpNoticeRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "通知标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于国庆放假的通知")
    private String title;
    @Schema(description = "通知正文")
    private String content;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
    @Schema(description = "状态名", example = "已发布")
    private String statusName;
    @Schema(description = "推送范围", example = "0")
    private Integer pushScope;
    @Schema(description = "推送范围名", example = "全员")
    private String pushScopeName;
    @Schema(description = "推送目标")
    private String pushTarget;
    @Schema(description = "推送状态:0 æœªæŽ¨é€ã€1 å·²æŽ¨é€", example = "1")
    private Integer pushStatus;
    @Schema(description = "最近推送时间")
    private LocalDateTime pushTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥ åˆ›å»º/修改 Request VO")
@Data
public class WxCpNoticeSaveReqVO {
    @Schema(description = "编号", example = "1024")
    private Long id;
    @Schema(description = "通知标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于国庆放假的通知")
    @NotEmpty(message = "通知标题不能为空")
    @Size(max = 128, message = "通知标题长度不能超过 128 ä¸ªå­—符")
    private String title;
    @Schema(description = "通知正文", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "通知正文不能为空")
    private String content;
    @Schema(description = "推送范围(选填,不传默认全员);推送时仍可在推送弹窗里重新选择", example = "0")
    @InEnum(WxCpPushScopeEnum.class)
    private Integer pushScope;
    @Schema(description = "推送目标:部门ID或成员账号,多个用逗号分隔", example = "1,2 æˆ– LiuYi,ZhangSan")
    @Size(max = 1024, message = "推送目标长度不能超过 1024 ä¸ªå­—符")
    private String pushTarget;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注长度不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æŽ¨é€ Request VO(活动与通知共用)")
@Data
public class WxCpPushReqVO {
    @Schema(description = "业务编号(活动编号或通知编号)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    @NotNull(message = "编号不能为空")
    private Long id;
    @Schema(description = "推送范围;不传则沿用记录上已配置的范围", example = "0")
    @InEnum(WxCpPushScopeEnum.class)
    private Integer pushScope;
    @Schema(description = "推送目标;不传则沿用记录上已配置的目标", example = "LiuYi,ZhangSan")
    @Size(max = 1024, message = "推送目标长度不能超过 1024 ä¸ªå­—符")
    private String pushTarget;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushResultVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ¶ˆæ¯æŽ¨é€ç»“æžœ VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WxCpPushResultVO {
    @Schema(description = "是否推送成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
    private Boolean success;
    @Schema(description = "企业微信返回的错误码,0 è¡¨ç¤ºæˆåŠŸ", example = "0")
    private Integer errCode;
    @Schema(description = "结果说明")
    private String message;
    @Schema(description = "实际发送的成员账号(多个用 | åˆ†éš”)")
    private String toUser;
    @Schema(description = "实际发送的部门ID(多个用 | åˆ†éš”)")
    private String toParty;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.activity;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ DO
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_activity")
@KeySequence("wxcp_activity_seq") // ç”¨äºŽ Oracle、PostgreSQL、Kingbase、DB2、H2 æ•°æ®åº“的主键自增。如果是 MySQL ç­‰æ•°æ®åº“,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpActivityDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ´»åŠ¨æ ‡é¢˜
     */
    private String title;
    /**
     * æ´»åŠ¨è¯¦æƒ…
     */
    private String content;
    /**
     * å°é¢å›¾åœ°å€
     */
    private String coverUrl;
    /**
     * æ´»åŠ¨åœ°ç‚¹
     */
    private String location;
    /**
     * æ´»åŠ¨å¼€å§‹æ—¶é—´
     */
    private LocalDateTime startTime;
    /**
     * æ´»åŠ¨ç»“æŸæ—¶é—´
     */
    private LocalDateTime endTime;
    /**
     * æŠ¥åå¼€å§‹æ—¶é—´
     */
    private LocalDateTime signupStartTime;
    /**
     * æŠ¥åç»“束时间
     */
    private LocalDateTime signupEndTime;
    /**
     * æŠ¥åäººæ•°ä¸Šé™ï¼Œ0 è¡¨ç¤ºä¸é™
     */
    private Integer signupLimit;
    /**
     * å·²æŠ¥åäººæ•°ï¼ˆå†—余字段,便于列表展示)
     */
    private Integer signupCount;
    /**
     * çŠ¶æ€
     *
     * æžšä¸¾ {@link WxCpActivityStatusEnum}
     */
    private Integer status;
    /**
     * æŽ¨é€èŒƒå›´
     *
     * æžšä¸¾ {@link WxCpPushScopeEnum}
     */
    private Integer pushScope;
    /**
     * æŽ¨é€ç›®æ ‡ï¼šéƒ¨é—¨ID或成员账号,多个用逗号分隔
     */
    private String pushTarget;
    /**
     * æŽ¨é€çŠ¶æ€ï¼š0 æœªæŽ¨é€ã€1 å·²æŽ¨é€
     */
    private Integer pushStatus;
    /**
     * æœ€è¿‘推送时间
     */
    private LocalDateTime pushTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.activity;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥åè®°å½• DO
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_activity_signup")
@KeySequence("wxcp_activity_signup_seq") // ç”¨äºŽ Oracle、PostgreSQL、Kingbase、DB2、H2 æ•°æ®åº“的主键自增。如果是 MySQL ç­‰æ•°æ®åº“,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpActivitySignupDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * æ´»åŠ¨ç¼–å·
     *
     * å…³è” {@link WxCpActivityDO#getId()}
     */
    private Long activityId;
    /**
     * ä¼ä¸šå¾®ä¿¡æˆå‘˜è´¦å·ï¼ˆUserId)
     */
    private String wxUserId;
    /**
     * å§“名
     */
    private String name;
    /**
     * æ‰‹æœºå·
     */
    private String mobile;
    /**
     * éƒ¨é—¨åç§°
     */
    private String deptName;
    /**
     * å‚与说明(成员提交的文字内容)
     */
    private String content;
    /**
     * çŠ¶æ€
     *
     * æžšä¸¾ {@link WxCpSignupStatusEnum}
     */
    private Integer status;
    /**
     * æŠ¥åæ—¶é—´
     */
    private LocalDateTime signupTime;
    /**
     * ç­¾åˆ°æ—¶é—´
     */
    private LocalDateTime checkInTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.notice;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ DO
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_notice")
@KeySequence("wxcp_notice_seq") // ç”¨äºŽ Oracle、PostgreSQL、Kingbase、DB2、H2 æ•°æ®åº“的主键自增。如果是 MySQL ç­‰æ•°æ®åº“,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpNoticeDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * é€šçŸ¥æ ‡é¢˜
     */
    private String title;
    /**
     * é€šçŸ¥æ­£æ–‡
     */
    private String content;
    /**
     * çŠ¶æ€
     *
     * æžšä¸¾ {@link WxCpNoticeStatusEnum}
     */
    private Integer status;
    /**
     * æŽ¨é€èŒƒå›´
     *
     * æžšä¸¾ {@link WxCpPushScopeEnum}
     */
    private Integer pushScope;
    /**
     * æŽ¨é€ç›®æ ‡ï¼šéƒ¨é—¨ID或成员账号,多个用逗号分隔
     */
    private String pushTarget;
    /**
     * æŽ¨é€çŠ¶æ€ï¼š0 æœªæŽ¨é€ã€1 å·²æŽ¨é€
     */
    private Integer pushStatus;
    /**
     * æœ€è¿‘推送时间
     */
    private LocalDateTime pushTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivityMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.activity;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpActivityPageReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import org.apache.ibatis.annotations.Mapper;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpActivityMapper extends BaseMapperX<WxCpActivityDO> {
    default PageResult<WxCpActivityDO> selectPage(WxCpActivityPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<WxCpActivityDO>()
                .likeIfPresent(WxCpActivityDO::getTitle, reqVO.getTitle())
                .eqIfPresent(WxCpActivityDO::getStatus, reqVO.getStatus())
                .eqIfPresent(WxCpActivityDO::getPushStatus, reqVO.getPushStatus())
                .betweenIfPresent(WxCpActivityDO::getStartTime, reqVO.getStartTime())
                .orderByDesc(WxCpActivityDO::getId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivitySignupMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.activity;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpSignupPageReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥å Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpActivitySignupMapper extends BaseMapperX<WxCpActivitySignupDO> {
    default WxCpActivitySignupDO selectByActivityIdAndWxUserId(Long activityId, String wxUserId) {
        return CollUtil.getFirst(selectList(new LambdaQueryWrapperX<WxCpActivitySignupDO>()
                .eq(WxCpActivitySignupDO::getActivityId, activityId)
                .eq(WxCpActivitySignupDO::getWxUserId, wxUserId)
                .orderByAsc(WxCpActivitySignupDO::getId)));
    }
    /**
     * æŒ‰æ‰‹æœºå·æŸ¥æŠ¥åè®°å½•
     *
     * æ™®é€šæµè§ˆå™¨æ‰«ç æŠ¥åæ—¶æ‹¿ä¸åˆ°ä¼ä¸šå¾®ä¿¡æˆå‘˜è´¦å·ï¼Œæ”¹ç”¨æ‰‹æœºå·åšåŽ»é‡ä¾æ®
     */
    default WxCpActivitySignupDO selectByActivityIdAndMobile(Long activityId, String mobile) {
        return CollUtil.getFirst(selectList(new LambdaQueryWrapperX<WxCpActivitySignupDO>()
                .eq(WxCpActivitySignupDO::getActivityId, activityId)
                .eq(WxCpActivitySignupDO::getMobile, mobile)
                .orderByAsc(WxCpActivitySignupDO::getId)));
    }
    default List<WxCpActivitySignupDO> selectListByActivityId(Long activityId) {
        return selectList(new LambdaQueryWrapperX<WxCpActivitySignupDO>()
                .eq(WxCpActivitySignupDO::getActivityId, activityId)
                .orderByAsc(WxCpActivitySignupDO::getId));
    }
    default PageResult<WxCpActivitySignupDO> selectPage(WxCpSignupPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<WxCpActivitySignupDO>()
                .eqIfPresent(WxCpActivitySignupDO::getActivityId, reqVO.getActivityId())
                .likeIfPresent(WxCpActivitySignupDO::getName, reqVO.getName())
                .likeIfPresent(WxCpActivitySignupDO::getMobile, reqVO.getMobile())
                .eqIfPresent(WxCpActivitySignupDO::getStatus, reqVO.getStatus())
                .orderByDesc(WxCpActivitySignupDO::getId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/notice/WxCpNoticeMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.notice;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import org.apache.ibatis.annotations.Mapper;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpNoticeMapper extends BaseMapperX<WxCpNoticeDO> {
    default PageResult<WxCpNoticeDO> selectPage(WxCpNoticePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<WxCpNoticeDO>()
                .likeIfPresent(WxCpNoticeDO::getTitle, reqVO.getTitle())
                .eqIfPresent(WxCpNoticeDO::getStatus, reqVO.getStatus())
                .eqIfPresent(WxCpNoticeDO::getPushStatus, reqVO.getPushStatus())
                .betweenIfPresent(WxCpNoticeDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(WxCpNoticeDO::getId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.wxcp.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * ä¼ä¸šå¾®ä¿¡æ¨¡å—错误码
 *
 * æ®µä½ï¼š1-050-000-000,与其它模块不冲突
 *
 * @author è¶…级管理员
 */
public interface ErrorCodeConstants {
    // ========== æ´»åЍ 1-050-000-000 ==========
    ErrorCode ACTIVITY_NOT_EXISTS = new ErrorCode(1_050_000_000, "活动不存在");
    ErrorCode ACTIVITY_CANNOT_UPDATE = new ErrorCode(1_050_000_001, "活动已结束或已取消,无法修改");
    ErrorCode ACTIVITY_STATUS_NOT_PUBLISHED = new ErrorCode(1_050_000_002, "活动尚未发布,请先发布后再推送");
    ErrorCode ACTIVITY_STATUS_FINISHED = new ErrorCode(1_050_000_003, "活动已结束或已取消,无法继续操作");
    ErrorCode ACTIVITY_PUSH_TARGET_EMPTY = new ErrorCode(1_050_000_004, "推送范围选择「指定成员」或「指定部门」时,推送目标不能为空");
    ErrorCode ACTIVITY_PUSH_FAILED = new ErrorCode(1_050_000_005, "活动推送失败");
    // ========== æŠ¥å 1-050-001-000 ==========
    ErrorCode SIGNUP_ACTIVITY_NOT_PUBLISHED = new ErrorCode(1_050_001_000, "活动未发布,暂不能报名");
    ErrorCode SIGNUP_NOT_IN_PERIOD = new ErrorCode(1_050_001_001, "不在报名时间内");
    ErrorCode SIGNUP_DUPLICATED = new ErrorCode(1_050_001_002, "您已报名该活动,请勿重复提交");
    ErrorCode SIGNUP_FULL = new ErrorCode(1_050_001_003, "报名人数已满");
    ErrorCode SIGNUP_NOT_EXISTS = new ErrorCode(1_050_001_004, "报名记录不存在");
    ErrorCode SIGNUP_NOT_SIGNED_UP = new ErrorCode(1_050_001_005, "该成员未报名此活动,无法签到");
    ErrorCode SIGNUP_ALREADY_CANCELED = new ErrorCode(1_050_001_006, "该报名已取消");
    ErrorCode SIGNUP_ALREADY_CHECKED_IN = new ErrorCode(1_050_001_007, "该报名已签到,请勿重复操作");
    ErrorCode SIGNUP_WX_USER_REQUIRED = new ErrorCode(1_050_001_008, "未获取到企业微信成员身份,请从企业微信内打开报名页");
    // ========== äºŒç»´ç  1-050-002-000 ==========
    ErrorCode QRCODE_GENERATE_FAILED = new ErrorCode(1_050_002_000, "生成报名二维码失败");
    ErrorCode QRCODE_SIGNUP_URL_NOT_CONFIGURED = new ErrorCode(1_050_002_001,
            "未配置报名页外网地址(wx.cp.signup-url-prefix),无法生成二维码,请先在 application é…ç½®ä¸­è¡¥ä¸Š");
    // ========== é€šçŸ¥ 1-050-003-000 ==========
    ErrorCode NOTICE_NOT_EXISTS = new ErrorCode(1_050_003_000, "通知不存在");
    ErrorCode NOTICE_CANNOT_UPDATE = new ErrorCode(1_050_003_001, "通知已撤回,无法修改");
    ErrorCode NOTICE_STATUS_REVOKED = new ErrorCode(1_050_003_002, "通知已撤回,无法发布");
    ErrorCode NOTICE_STATUS_NOT_PUBLISHED = new ErrorCode(1_050_003_003, "通知尚未发布,请先发布后再推送");
    // ========== ä¼ä¸šå¾®ä¿¡é€šè®¯å½• 1-050-004-000 ==========
    ErrorCode WXCP_API_ERROR = new ErrorCode(1_050_004_000, "调用企业微信接口失败");
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpActivityStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.wxcp.enums.activity;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨çŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum WxCpActivityStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "草稿"),
    PUBLISHED(1, "已发布"),
    FINISHED(2, "已结束"),
    CANCELED(3, "已取消"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpActivityStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€å€¼
     */
    private final Integer status;
    /**
     * çŠ¶æ€å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpActivityStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(o -> o.getStatus().equals(status), values());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpPushScopeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.wxcp.enums.activity;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŽ¨é€èŒƒå›´æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum WxCpPushScopeEnum implements ArrayValuable<Integer> {
    ALL(0, "全员"),
    PARTY(1, "指定部门"),
    USER(2, "指定成员"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpPushScopeEnum::getScope).toArray(Integer[]::new);
    /**
     * èŒƒå›´å€¼
     */
    private final Integer scope;
    /**
     * èŒƒå›´å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpPushScopeEnum valueOf(Integer scope) {
        return ArrayUtil.firstMatch(o -> o.getScope().equals(scope), values());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpSignupStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.wxcp.enums.activity;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥åçŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum WxCpSignupStatusEnum implements ArrayValuable<Integer> {
    SIGNED_UP(0, "已报名"),
    CANCELED(1, "已取消"),
    CHECKED_IN(2, "已签到"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpSignupStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€å€¼
     */
    private final Integer status;
    /**
     * çŠ¶æ€å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpSignupStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(o -> o.getStatus().equals(status), values());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.wxcp.enums.notice;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥çŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum WxCpNoticeStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "草稿"),
    PUBLISHED(1, "已发布"),
    REVOKED(2, "已撤回"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpNoticeStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€å€¼
     */
    private final Integer status;
    /**
     * çŠ¶æ€å
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpNoticeStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(o -> o.getStatus().equals(status), values());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/config/WxCpProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.wxcp.framework.config;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
 * ä¼ä¸šå¾®ä¿¡é…ç½®å±žæ€§ç±»
 *
 * @author è¶…级管理员
 */
@Data
@Validated
@ConfigurationProperties(prefix = "wx.cp")
public class WxCpProperties {
    /**
     * ä¼ä¸šå¾®ä¿¡ CorpId(企业ID)
     */
    @NotEmpty(message = "企业微信 CorpId ä¸èƒ½ä¸ºç©º")
    private String corpId;
    /**
     * åº”用 AgentId
     */
    @NotEmpty(message = "企业微信 AgentId ä¸èƒ½ä¸ºç©º")
    private String agentId;
    /**
     * åº”用 Secret
     */
    @NotEmpty(message = "企业微信 Secret ä¸èƒ½ä¸ºç©º")
    private String secret;
    /**
     * Token(回调 URL éªŒè¯ç”¨ï¼Œéœ€åœ¨ä¼ä¸šå¾®ä¿¡ç®¡ç†åŽå°é…ç½®ï¼‰
     */
    private String token;
    /**
     * EncodingAESKey(回调消息加解密用,需在企业微信管理后台配置)
     */
    private String aesKey;
    /**
     * æŠ¥åé¡µå¤–网地址前缀,用于生成活动报名二维码
     *
     * å¿…须是企业微信成员手机能访问到的地址,例如内网穿透域名或正式域名;
     * ç”Ÿæˆçš„二维码内容为 {@code {signupUrlPrefix}/admin-api/wxcp/activity/signup-page?id={活动编号}}
     */
    private String signupUrlPrefix;
    /**
     * ç”ŸæˆäºŒç»´ç çš„边长(像素),不配置时默认 400
     */
    private Integer qrcodeSize;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/config/YudaoWxCpConfiguration.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.wxcp.framework.config;
import cn.iocoder.yudao.module.wxcp.framework.core.WxCpClientFactory;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
 * ä¼ä¸šå¾®ä¿¡é…ç½®ç±»
 *
 * æ³¨æ„ï¼šä¸šåŠ¡æ¨¡å—ä½äºŽç»„ä»¶æ‰«æè·¯å¾„ cn.iocoder.yudao.module ä¸‹ï¼Œå› æ­¤ä½¿ç”¨ @Configuration;
 * è‹¥ç”¨ @AutoConfiguration ä¼šè¢« Spring Boot çš„ AutoConfigurationExcludeFilter æŽ’除,导致 Bean ä¸æ³¨å†Œ
 *
 * @author è¶…级管理员
 */
@Configuration(proxyBeanMethods = false)
@Slf4j
@ConditionalOnClass(WxCpService.class)
@EnableConfigurationProperties(WxCpProperties.class)
@ConditionalOnProperty(value = "wx.cp.enabled", havingValue = "true", matchIfMissing = true)
public class YudaoWxCpConfiguration {
    /**
     * åˆ›å»ºä¼ä¸šå¾®ä¿¡ WxCpService Bean(单应用模式)
     */
    @Bean
    public WxCpService wxCpService(WxCpProperties properties) {
        WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl();
        config.setCorpId(properties.getCorpId());
        config.setAgentId(Integer.valueOf(properties.getAgentId()));
        config.setCorpSecret(properties.getSecret());
        config.setToken(properties.getToken());
        config.setAesKey(properties.getAesKey());
        WxCpServiceImpl wxCpService = new WxCpServiceImpl();
        wxCpService.setWxCpConfigStorage(config);
        log.info("[wxCpService][企业微信服务初始化完成 corpId={} agentId={}]",
                properties.getCorpId(), properties.getAgentId());
        return wxCpService;
    }
    /**
     * åˆ›å»ºä¼ä¸šå¾®ä¿¡å®¢æˆ·ç«¯å·¥åŽ‚ï¼ˆæ”¯æŒå¤šåº”ç”¨ï¼‰
     */
    @Bean
    public WxCpClientFactory wxCpClientFactory(WxCpProperties properties) {
        return new WxCpClientFactory(properties);
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/core/WxCpClientFactory.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.wxcp.framework.core;
import cn.iocoder.yudao.module.wxcp.framework.config.WxCpProperties;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
/**
 * ä¼ä¸šå¾®ä¿¡å®¢æˆ·ç«¯å·¥åŽ‚
 *
 * æ”¯æŒå¤šåº”用场景,每个应用有自己的 AgentId å’Œ Secret
 *
 * @author è¶…级管理员
 */
@Slf4j
public class WxCpClientFactory {
    private final WxCpProperties properties;
    public WxCpClientFactory(WxCpProperties properties) {
        this.properties = properties;
    }
    /**
     * èŽ·å–é»˜è®¤åº”ç”¨ï¼ˆä¸»åº”ç”¨ï¼‰çš„ WxCpService
     */
    public WxCpService getService() {
        return buildService(properties.getAgentId(), properties.getSecret());
    }
    /**
     * èŽ·å–æŒ‡å®š AgentId çš„ WxCpService
     *
     * @param agentId åº”用 AgentId
     * @param secret  åº”用 Secret
     */
    public WxCpService getService(String agentId, String secret) {
        return buildService(agentId, secret);
    }
    private WxCpService buildService(String agentId, String secret) {
        WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl();
        config.setCorpId(properties.getCorpId());
        config.setAgentId(Integer.valueOf(agentId));
        config.setCorpSecret(secret);
        config.setToken(properties.getToken());
        config.setAesKey(properties.getAesKey());
        WxCpServiceImpl wxCpService = new WxCpServiceImpl();
        wxCpService.setWxCpConfigStorage(config);
        return wxCpService;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/framework/qrcode/WxCpQrCodeUtil.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package cn.iocoder.yudao.module.wxcp.framework.qrcode;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.ByteArrayOutputStream;
import java.util.HashMap;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.QRCODE_GENERATE_FAILED;
/**
 * ä¼ä¸šå¾®ä¿¡æ¨¡å— - äºŒç»´ç ç”Ÿæˆå·¥å…·
 *
 * ç”¨äºŽæŠŠæ´»åŠ¨æŠ¥åé¡µåœ°å€ç”ŸæˆäºŒç»´ç å›¾ç‰‡ï¼Œä¾›ä¼ä¸šå¾®ä¿¡æŽ¨é€æˆ–åŽå°ä¸‹è½½
 *
 * @author è¶…级管理员
 */
public class WxCpQrCodeUtil {
    /**
     * é»˜è®¤äºŒç»´ç è¾¹é•¿ï¼ˆåƒç´ ï¼‰
     */
    public static final int DEFAULT_SIZE = 400;
    private WxCpQrCodeUtil() {
    }
    /**
     * ç”ŸæˆäºŒç»´ç  PNG å­—节流
     *
     * @param content äºŒç»´ç å†…容(一般是一个 URL)
     * @param size    è¾¹é•¿åƒç´ ï¼Œä¼  0 æˆ–负数则使用默认值
     * @return PNG å›¾ç‰‡å­—节
     */
    public static byte[] generatePng(String content, int size) {
        int actualSize = size > 0 ? size : DEFAULT_SIZE;
        try {
            Map<EncodeHintType, Object> hints = new HashMap<>(4);
            hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
            // å®¹é”™çº§åˆ« M:约 15%,兼顾扫码成功率与码点密度,适合印在物料上
            hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
            hints.put(EncodeHintType.MARGIN, 1);
            BitMatrix matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE,
                    actualSize, actualSize, hints);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            MatrixToImageWriter.writeToStream(matrix, "PNG", out);
            return out.toByteArray();
        } catch (Exception e) {
            throw exception(QRCODE_GENERATE_FAILED);
        }
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,165 @@
package cn.iocoder.yudao.module.wxcp.service.activity;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.*;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface WxCpActivityService {
    // ==================== æ´»åŠ¨ç®¡ç† ====================
    /**
     * åˆ›å»ºæ´»åŠ¨ï¼ˆåˆå§‹ä¸ºè‰ç¨¿çŠ¶æ€ï¼‰
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @return æ´»åŠ¨ç¼–å·
     */
    Long createActivity(@Valid WxCpActivitySaveReqVO createReqVO);
    /**
     * ä¿®æ”¹æ´»åЍ
     *
     * @param updateReqVO ä¿®æ”¹ä¿¡æ¯
     */
    void updateActivity(@Valid WxCpActivitySaveReqVO updateReqVO);
    /**
     * åˆ é™¤æ´»åЍ
     *
     * @param id æ´»åŠ¨ç¼–å·
     */
    void deleteActivity(Long id);
    /**
     * èŽ·å¾—æ´»åŠ¨
     *
     * @param id æ´»åŠ¨ç¼–å·
     * @return æ´»åЍ
     */
    WxCpActivityDO getActivity(Long id);
    /**
     * èŽ·å¾—æ´»åŠ¨åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return æ´»åŠ¨åˆ†é¡µ
     */
    PageResult<WxCpActivityDO> getActivityPage(@Valid WxCpActivityPageReqVO pageReqVO);
    /**
     * å‘布活动(草稿 -&gt; å·²å‘布)
     *
     * @param id æ´»åŠ¨ç¼–å·
     */
    void publishActivity(Long id);
    /**
     * ç»“束活动
     *
     * @param id æ´»åŠ¨ç¼–å·
     */
    void finishActivity(Long id);
    /**
     * å–消活动
     *
     * @param id æ´»åŠ¨ç¼–å·
     */
    void cancelActivity(Long id);
    // ==================== æŽ¨é€ ====================
    /**
     * æŽ¨é€æ´»åŠ¨ç»™ä¼ä¸šå¾®ä¿¡æˆå‘˜
     *
     * @param pushReqVO æŽ¨é€ä¿¡æ¯
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushActivity(@Valid WxCpPushReqVO pushReqVO);
    /**
     * ç”Ÿæˆæ´»åŠ¨æŠ¥åäºŒç»´ç ï¼ˆPNG å­—节流)
     *
     * @param id   æ´»åŠ¨ç¼–å·
     * @param size è¾¹é•¿åƒç´ ï¼Œä¼  0 ä½¿ç”¨é»˜è®¤å€¼
     * @return PNG å­—节
     */
    byte[] generateSignupQrCode(Long id, int size);
    /**
     * æ‹¼è£…活动报名页地址
     *
     * @param id æ´»åŠ¨ç¼–å·
     * @return æŠ¥åé¡µå®Œæ•´ URL
     */
    String buildSignupUrl(Long id);
    // ==================== æŠ¥å ====================
    /**
     * æäº¤æŠ¥åï¼ˆå…ç™»å½•接口使用)
     *
     * @param reqVO æŠ¥åä¿¡æ¯
     * @return æŠ¥åè®°å½•编号
     */
    Long createSignup(@Valid WxCpSignupPublicReqVO reqVO);
    /**
     * å–消报名
     *
     * @param id æŠ¥åè®°å½•编号
     */
    void cancelSignup(Long id);
    /**
     * æŠ¥åç­¾åˆ°
     *
     * @param id æŠ¥åè®°å½•编号
     */
    void checkInSignup(Long id);
    /**
     * èŽ·å¾—æŠ¥åè®°å½•
     *
     * @param id æŠ¥åè®°å½•编号
     * @return æŠ¥åè®°å½•
     */
    WxCpActivitySignupDO getSignup(Long id);
    /**
     * èŽ·å¾—æŠ¥åè®°å½•åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return æŠ¥åè®°å½•分页
     */
    PageResult<WxCpActivitySignupDO> getSignupPage(@Valid WxCpSignupPageReqVO pageReqVO);
    /**
     * èŽ·å¾—æŸæ¡æŠ¥åè®°å½•çš„é™„ä»¶åˆ—è¡¨
     *
     * @param signupId æŠ¥åè®°å½•编号
     * @return é™„件(含访问地址)
     */
    List<SystemStorageBlobRespVO> getSignupAttachments(Long signupId);
    /**
     * èŽ·å¾—æ´»åŠ¨æŠ¥åç»Ÿè®¡
     *
     * @param activityId æ´»åŠ¨ç¼–å·
     * @return ç»Ÿè®¡æ•°æ®
     */
    WxCpActivityStatsRespVO getActivityStats(Long activityId);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,473 @@
package cn.iocoder.yudao.module.wxcp.service.activity;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageAttachmentService;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.*;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivityMapper;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivitySignupMapper;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.framework.config.WxCpProperties;
import cn.iocoder.yudao.module.wxcp.framework.qrcode.WxCpQrCodeUtil;
import cn.iocoder.yudao.module.wxcp.service.push.WxCpMessagePushService;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.*;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class WxCpActivityServiceImpl implements WxCpActivityService {
    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    /**
     * æŠ¥åé™„件的关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String SIGNUP_RECORD_TYPE = "wxcp_activity_signup";
    @Resource
    private WxCpActivityMapper wxcpActivityMapper;
    @Resource
    private WxCpActivitySignupMapper wxcpSignupMapper;
    @Resource
    private SystemStorageAttachmentService storageAttachmentService;
    @Resource
    private WxCpMessagePushService messagePushService;
    @Resource
    private WxCpProperties wxCpProperties;
    // ==================== æ´»åŠ¨ç®¡ç† ====================
    @Override
    public Long createActivity(WxCpActivitySaveReqVO createReqVO) {
        WxCpActivityDO activity = BeanUtils.toBean(createReqVO, WxCpActivityDO.class);
        // æ–°å»ºçš„一律是草稿,避免未配置完就被扫码报名
        activity.setStatus(WxCpActivityStatusEnum.DRAFT.getStatus());
        activity.setSignupCount(0);
        activity.setPushStatus(0);
        // æŽ¨é€èŒƒå›´é€‰å¡«ï¼Œä¸ä¼ é»˜è®¤å…¨å‘˜ï¼›æŽ¨é€æ—¶è¿˜èƒ½åœ¨å¼¹çª—里改
        if (activity.getPushScope() == null) {
            activity.setPushScope(WxCpPushScopeEnum.ALL.getScope());
        }
        wxcpActivityMapper.insert(activity);
        return activity.getId();
    }
    @Override
    public void updateActivity(WxCpActivitySaveReqVO updateReqVO) {
        WxCpActivityDO activity = validateActivityExists(updateReqVO.getId());
        // å·²ç»“束或已取消的活动不再允许修改
        if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.FINISHED.getStatus())
                || Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.CANCELED.getStatus())) {
            throw exception(ACTIVITY_CANNOT_UPDATE);
        }
        WxCpActivityDO updateObj = BeanUtils.toBean(updateReqVO, WxCpActivityDO.class);
        // ä»¥ä¸‹å­—段由系统维护,不允许前端覆盖
        updateObj.setStatus(null);
        updateObj.setSignupCount(null);
        updateObj.setPushStatus(null);
        updateObj.setPushTime(null);
        wxcpActivityMapper.updateById(updateObj);
    }
    @Override
    public void deleteActivity(Long id) {
        validateActivityExists(id);
        wxcpActivityMapper.deleteById(id);
    }
    @Override
    public WxCpActivityDO getActivity(Long id) {
        return wxcpActivityMapper.selectById(id);
    }
    @Override
    public PageResult<WxCpActivityDO> getActivityPage(WxCpActivityPageReqVO pageReqVO) {
        return wxcpActivityMapper.selectPage(pageReqVO);
    }
    @Override
    public void publishActivity(Long id) {
        WxCpActivityDO activity = validateActivityExists(id);
        if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
            return; // å·²å‘布,幂等返回
        }
        if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.FINISHED.getStatus())
                || Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.CANCELED.getStatus())) {
            throw exception(ACTIVITY_STATUS_FINISHED);
        }
        wxcpActivityMapper.updateById(WxCpActivityDO.builder()
                .id(id).status(WxCpActivityStatusEnum.PUBLISHED.getStatus()).build());
    }
    @Override
    public void finishActivity(Long id) {
        validateActivityExists(id);
        wxcpActivityMapper.updateById(WxCpActivityDO.builder()
                .id(id).status(WxCpActivityStatusEnum.FINISHED.getStatus()).build());
    }
    @Override
    public void cancelActivity(Long id) {
        validateActivityExists(id);
        wxcpActivityMapper.updateById(WxCpActivityDO.builder()
                .id(id).status(WxCpActivityStatusEnum.CANCELED.getStatus()).build());
    }
    // ==================== æŽ¨é€ ====================
    @Override
    public WxCpPushResultVO pushActivity(WxCpPushReqVO pushReqVO) {
        WxCpActivityDO activity = validateActivityExists(pushReqVO.getId());
        if (!Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
            throw exception(ACTIVITY_STATUS_NOT_PUBLISHED);
        }
        // æŽ¨é€èŒƒå›´ä¸Žç›®æ ‡ï¼šè¯·æ±‚里传了就用请求的,否则沿用活动上配置的
        Integer scope = pushReqVO.getPushScope() != null ? pushReqVO.getPushScope() : activity.getPushScope();
        String target = StrUtil.isNotBlank(pushReqVO.getPushTarget())
                ? pushReqVO.getPushTarget() : activity.getPushTarget();
        if (scope == null) {
            scope = WxCpPushScopeEnum.ALL.getScope();
        }
        // æ–‡æœ¬å¡ç‰‡æ¶ˆæ¯ï¼šæŒ‰é’®ç›´è¾¾æŠ¥åé¡µï¼Œç‚¹æ¶ˆæ¯æˆ–扫码都能报名
        WxCpPushResultVO result = messagePushService.pushCard(
                activity.getTitle(), buildPushDescription(activity),
                buildSignupUrl(activity.getId()), "立即报名", scope, target);
        if (Boolean.TRUE.equals(result.getSuccess())) {
            wxcpActivityMapper.updateById(WxCpActivityDO.builder()
                    .id(activity.getId())
                    .pushScope(scope)
                    .pushTarget(target)
                    .pushStatus(1)
                    .pushTime(LocalDateTime.now())
                    .build());
        }
        return result;
    }
    /**
     * æ‹¼è£…推送消息正文
     *
     * åªæ”¾æˆå‘˜æŠ¥åå‰æœ€å…³å¿ƒçš„信息:时间、地点、报名截止;详情让用户点进报名页看
     */
    private String buildPushDescription(WxCpActivityDO activity) {
        StringBuilder sb = new StringBuilder();
        if (activity.getStartTime() != null) {
            sb.append("活动时间:").append(activity.getStartTime().format(TIME_FMT));
            if (activity.getEndTime() != null) {
                sb.append(" ~ ").append(activity.getEndTime().format(TIME_FMT));
            }
            sb.append("\n");
        }
        if (StrUtil.isNotBlank(activity.getLocation())) {
            sb.append("活动地点:").append(activity.getLocation()).append("\n");
        }
        if (activity.getSignupEndTime() != null) {
            sb.append("报名截止:").append(activity.getSignupEndTime().format(TIME_FMT)).append("\n");
        }
        if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0) {
            sb.append("名额:").append(activity.getSignupCount()).append("/")
                    .append(activity.getSignupLimit()).append("\n");
        }
        sb.append("点击下方按钮查看详情并报名");
        return sb.toString();
    }
    @Override
    public byte[] generateSignupQrCode(Long id, int size) {
        validateActivityExists(id);
        String url = buildSignupUrl(id);
        Integer configured = wxCpProperties.getQrcodeSize();
        int actualSize = size > 0 ? size : (configured != null ? configured : WxCpQrCodeUtil.DEFAULT_SIZE);
        return WxCpQrCodeUtil.generatePng(url, actualSize);
    }
    @Override
    public String buildSignupUrl(Long id) {
        String prefix = wxCpProperties.getSignupUrlPrefix();
        if (StrUtil.isBlank(prefix)) {
            throw exception(QRCODE_SIGNUP_URL_NOT_CONFIGURED);
        }
        // åŽ»æŽ‰ç»“å°¾æ–œæ ï¼Œé¿å…æ‹¼å‡º //admin-api
        String base = StrUtil.removeSuffix(prefix.trim(), "/");
        return base + "/admin-api/wxcp/activity/signup-page?id=" + id;
    }
    // ==================== æŠ¥å ====================
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createSignup(WxCpSignupPublicReqVO reqVO) {
        WxCpActivityDO activity = validateActivityExists(reqVO.getActivityId());
        // 1. æ´»åŠ¨å¿…é¡»æ˜¯å·²å‘å¸ƒçŠ¶æ€
        if (!Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
            throw exception(SIGNUP_ACTIVITY_NOT_PUBLISHED);
        }
        // 2. æ ¡éªŒæŠ¥åæ—¶é—´çª—口
        LocalDateTime now = LocalDateTime.now();
        if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) {
            throw exception(SIGNUP_NOT_IN_PERIOD);
        }
        if (activity.getSignupEndTime() != null && now.isAfter(activity.getSignupEndTime())) {
            throw exception(SIGNUP_NOT_IN_PERIOD);
        }
        // 3. æ ¡éªŒåé¢
        if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0
                && activity.getSignupCount() != null && activity.getSignupCount() >= activity.getSignupLimit()) {
            throw exception(SIGNUP_FULL);
        }
        // 4. åŽ»é‡ï¼šæœ‰ä¼ä¸šå¾®ä¿¡è´¦å·æŒ‰è´¦å·æŸ¥ï¼Œå¦åˆ™æŒ‰æ‰‹æœºå·æŸ¥
        WxCpActivitySignupDO exists = StrUtil.isNotBlank(reqVO.getWxUserId())
                ? wxcpSignupMapper.selectByActivityIdAndWxUserId(activity.getId(), reqVO.getWxUserId().trim())
                : wxcpSignupMapper.selectByActivityIdAndMobile(activity.getId(), reqVO.getMobile().trim());
        if (exists != null && !Objects.equals(exists.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
            throw exception(SIGNUP_DUPLICATED);
        }
        // ä¹‹å‰å–消过,则复用原记录重新报名,避免同一个人留多条历史
        if (exists != null) {
            wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
                    .id(exists.getId())
                    .name(reqVO.getName())
                    .mobile(reqVO.getMobile())
                    .deptName(reqVO.getDeptName())
                    .content(reqVO.getContent())
                    .remark(reqVO.getRemark())
                    .status(WxCpSignupStatusEnum.SIGNED_UP.getStatus())
                    .signupTime(now)
                    .checkInTime(null)
                    .build());
            // é‡æ–°æŠ¥åæ—¶æ—§é™„件已失效,先清掉再绑新的
            storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(
                    SIGNUP_RECORD_TYPE, exists.getId());
            bindSignupAttachments(exists.getId(), reqVO.getBlobIds());
            incrementSignupCount(activity.getId());
            return exists.getId();
        }
        // 5. æ–°å¢žæŠ¥åè®°å½•
        WxCpActivitySignupDO signup = BeanUtils.toBean(reqVO, WxCpActivitySignupDO.class);
        signup.setWxUserId(StrUtil.trimToEmpty(reqVO.getWxUserId()));
        signup.setStatus(WxCpSignupStatusEnum.SIGNED_UP.getStatus());
        signup.setSignupTime(now);
        wxcpSignupMapper.insert(signup);
        // 6. ç»‘定报名人上传的图片/附件
        bindSignupAttachments(signup.getId(), reqVO.getBlobIds());
        // 7. ç”¨ SQL è‡ªå¢žï¼Œé¿å…å¹¶å‘下的读改写丢失更新
        incrementSignupCount(activity.getId());
        return signup.getId();
    }
    /**
     * ç»‘定报名附件
     *
     * èµ° system æ¨¡å—的附件中间表(record_type = wxcp_activity_signup),
     * ä¸æ–°å»º upload æŽ¥å£ã€ä¸è½æ–‡ä»¶åœ°å€å­—段,符合项目统一文件管理规范。
     */
    private void bindSignupAttachments(Long signupId, List<Long> blobIds) {
        if (CollUtil.isEmpty(blobIds)) {
            return;
        }
        SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
        bindReqVO.setApplication("file");
        bindReqVO.setRecordType(SIGNUP_RECORD_TYPE);
        bindReqVO.setRecordId(signupId);
        bindReqVO.setBlobItems(blobIds.stream()
                .filter(Objects::nonNull)
                .map(blobId -> {
                    SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem();
                    item.setBlobId(blobId);
                    return item;
                })
                .toList());
        storageAttachmentService.bindAttachments(bindReqVO);
    }
    @Override
    public List<SystemStorageBlobRespVO> getSignupAttachments(Long signupId) {
        SystemStorageAttachmentListReqVO listReqVO = new SystemStorageAttachmentListReqVO();
        listReqVO.setRecordType(SIGNUP_RECORD_TYPE);
        listReqVO.setRecordId(signupId);
        return storageAttachmentService.listAttachments(listReqVO);
    }
    private void incrementSignupCount(Long activityId) {
        wxcpActivityMapper.update(null, new LambdaUpdateWrapper<WxCpActivityDO>()
                .setSql("signup_count = signup_count + 1")
                .eq(WxCpActivityDO::getId, activityId));
    }
    private void decrementSignupCount(Long activityId) {
        wxcpActivityMapper.update(null, new LambdaUpdateWrapper<WxCpActivityDO>()
                .setSql("signup_count = CASE WHEN signup_count > 0 THEN signup_count - 1 ELSE 0 END")
                .eq(WxCpActivityDO::getId, activityId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelSignup(Long id) {
        WxCpActivitySignupDO signup = validateSignupExists(id);
        if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
            throw exception(SIGNUP_ALREADY_CANCELED);
        }
        wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
                .id(id).status(WxCpSignupStatusEnum.CANCELED.getStatus()).build());
        decrementSignupCount(signup.getActivityId());
    }
    @Override
    public void checkInSignup(Long id) {
        WxCpActivitySignupDO signup = validateSignupExists(id);
        if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
            throw exception(SIGNUP_ALREADY_CANCELED);
        }
        if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CHECKED_IN.getStatus())) {
            throw exception(SIGNUP_ALREADY_CHECKED_IN);
        }
        wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
                .id(id)
                .status(WxCpSignupStatusEnum.CHECKED_IN.getStatus())
                .checkInTime(LocalDateTime.now())
                .build());
    }
    @Override
    public WxCpActivitySignupDO getSignup(Long id) {
        return wxcpSignupMapper.selectById(id);
    }
    @Override
    public PageResult<WxCpActivitySignupDO> getSignupPage(WxCpSignupPageReqVO pageReqVO) {
        return wxcpSignupMapper.selectPage(pageReqVO);
    }
    @Override
    public WxCpActivityStatsRespVO getActivityStats(Long activityId) {
        WxCpActivityDO activity = validateActivityExists(activityId);
        List<WxCpActivitySignupDO> all = wxcpSignupMapper.selectListByActivityId(activityId);
        // ä¸€æ¬¡éåŽ†åŒæ—¶ç®—æ€»æ•°ä¸Žä¸‰ç±»åˆ†å¸ƒï¼Œé¿å…å¤šæ¬¡å…¨è¡¨æ‰«æ
        int signedUp = 0, checkedIn = 0, canceled = 0;
        Map<String, Integer> deptMap = new LinkedHashMap<>();
        Map<String, Integer> dateMap = new TreeMap<>();
        int fromWxCp = 0, fromForm = 0;
        for (WxCpActivitySignupDO s : all) {
            Integer st = s.getStatus();
            if (Objects.equals(st, WxCpSignupStatusEnum.CANCELED.getStatus())) {
                canceled++;
                continue; // å·²å–消不计入部门/日期分布,否则统计会虚高
            }
            if (Objects.equals(st, WxCpSignupStatusEnum.CHECKED_IN.getStatus())) {
                checkedIn++;
            } else {
                signedUp++;
            }
            deptMap.merge(StrUtil.blankToDefault(s.getDeptName(), "未填写部门"), 1, Integer::sum);
            if (s.getSignupTime() != null) {
                dateMap.merge(s.getSignupTime().toLocalDate().toString(), 1, Integer::sum);
            }
            if (StrUtil.isNotBlank(s.getWxUserId())) {
                fromWxCp++;
            } else {
                fromForm++;
            }
        }
        int totalSignup = signedUp + checkedIn;
        WxCpActivityStatsRespVO vo = new WxCpActivityStatsRespVO();
        vo.setActivityId(activity.getId());
        vo.setActivityTitle(activity.getTitle());
        vo.setSignupLimit(activity.getSignupLimit());
        vo.setTotalSignup(totalSignup);
        vo.setSignedUpCount(signedUp);
        vo.setCheckedInCount(checkedIn);
        vo.setCanceledCount(canceled);
        // åé¢ç›¸å…³æŒ‡æ ‡ï¼šä¸é™åé¢æ—¶ä¿æŒ null,前端据此显示"不限"
        Integer limit = activity.getSignupLimit();
        if (limit != null && limit > 0) {
            vo.setRemaining(Math.max(0, limit - totalSignup));
            vo.setSignupPercent(Math.min(100, (int) Math.round(totalSignup * 100.0 / limit)));
        }
        vo.setCheckInPercent(totalSignup == 0 ? 0
                : (int) Math.round(checkedIn * 100.0 / totalSignup));
        vo.setDeptStats(toNameCountList(deptMap));
        vo.setDateStats(toNameCountList(dateMap));
        // æ¥æºåˆ†å¸ƒï¼šç”¨æ˜¾å¼ put,避免双大括号初始化捕获循环中变更的局部变量
        Map<String, Integer> sourceMap = new LinkedHashMap<>();
        sourceMap.put("企业微信身份", fromWxCp);
        sourceMap.put("表单填写", fromForm);
        vo.setSourceStats(toNameCountList(sourceMap));
        return vo;
    }
    private List<WxCpActivityStatsRespVO.NameCount> toNameCountList(Map<String, Integer> map) {
        return map.entrySet().stream()
                .map(e -> new WxCpActivityStatsRespVO.NameCount(e.getKey(), e.getValue()))
                .toList();
    }
    // ==================== æ ¡éªŒè¾…助 ====================
    private WxCpActivityDO validateActivityExists(Long id) {
        if (id == null) {
            throw exception(ACTIVITY_NOT_EXISTS);
        }
        WxCpActivityDO activity = wxcpActivityMapper.selectById(id);
        if (activity == null) {
            throw exception(ACTIVITY_NOT_EXISTS);
        }
        return activity;
    }
    private WxCpActivitySignupDO validateSignupExists(Long id) {
        WxCpActivitySignupDO signup = wxcpSignupMapper.selectById(id);
        if (signup == null) {
            throw exception(SIGNUP_NOT_EXISTS);
        }
        return signup;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/listener/WxCpLoginEventListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.wxcp.service.listener;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import cn.iocoder.yudao.module.system.event.SocialLoginEvent;
import cn.iocoder.yudao.module.wxcp.service.notify.WxCpNotifyService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
 * ä¼ä¸šå¾®ä¿¡ç™»å½•事件监听器
 *
 * ç›‘听社交登录成功事件,当登录方式为企业微信时,发送登录通知消息
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class WxCpLoginEventListener {
    @Resource
    private WxCpNotifyService wxCpNotifyService;
    /**
     * ç›‘听社交登录事件
     *
     * ä½¿ç”¨ AFTER_COMMIT:确保事务提交后再发送通知,
     * å¦åˆ™å¼‚步线程查询绑定关系时会因事务未提交而查不到记录(首次自动注册场景尤其明显)
     * fallbackExecution = true:调用方无事务时也正常触发
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void onSocialLogin(SocialLoginEvent event) {
        // ä»…处理企业微信登录事件
        if (!SocialTypeEnum.WECHAT_ENTERPRISE.getType().equals(event.getSocialType())) {
            return;
        }
        log.info("[onSocialLogin][收到企业微信登录事件 userId={} username={}]",
                event.getUserId(), event.getUsername());
        // å‘送企业微信登录通知
        wxCpNotifyService.sendLoginNotification(
                event.getUserId(),
                event.getUsername(),
                event.getLoginType());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.wxcp.service.member;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpDepartmentRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpMemberRespVO;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡é€šè®¯å½• Service æŽ¥å£
 *
 * æ•°æ®å®žæ—¶å–自企业微信接口。企业微信对通讯录接口有频率限制,
 * è°ƒç”¨æ–¹ï¼ˆç®¡ç†åŽå°é¡µé¢ï¼‰åº”避免高频轮询。
 *
 * @author è¶…级管理员
 */
public interface WxCpMemberService {
    /**
     * èŽ·å¾—éƒ¨é—¨æ ‘ï¼ˆå«æˆå‘˜æ•°ï¼‰
     *
     * @return éƒ¨é—¨æ ‘,根节点为顶级部门
     */
    List<WxCpDepartmentRespVO> getDepartmentTree();
    /**
     * èŽ·å¾—éƒ¨é—¨å¹³é“ºåˆ—è¡¨
     *
     * @return éƒ¨é—¨åˆ—表
     */
    List<WxCpDepartmentRespVO> getDepartmentList();
    /**
     * èŽ·å¾—æˆå‘˜åˆ—è¡¨
     *
     * @param departmentId éƒ¨é—¨ID;传 null æ—¶å–根部门(即全部可见成员)
     * @param fetchChild   æ˜¯å¦é€’归获取子部门成员
     * @return æˆå‘˜åˆ—表
     */
    List<WxCpMemberRespVO> getMemberList(Long departmentId, Boolean fetchChild);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
package cn.iocoder.yudao.module.wxcp.service.member;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpDepartmentRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpMemberRespVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpDepart;
import me.chanjar.weixin.cp.bean.WxCpUser;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.WXCP_API_ERROR;
/**
 * ä¼ä¸šå¾®ä¿¡é€šè®¯å½• Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class WxCpMemberServiceImpl implements WxCpMemberService {
    /**
     * ä¼ä¸šå¾®ä¿¡æ ¹éƒ¨é—¨ID固定为 1
     */
    private static final Long ROOT_DEPARTMENT_ID = 1L;
    @Resource
    private WxCpService wxCpService;
    @Override
    public List<WxCpDepartmentRespVO> getDepartmentList() {
        List<WxCpDepart> departs = listDeparts();
        return departs.stream().map(this::toDeptVO).toList();
    }
    @Override
    public List<WxCpDepartmentRespVO> getDepartmentTree() {
        List<WxCpDepart> departs = listDeparts();
        Map<Long, WxCpDepartmentRespVO> voMap = new LinkedHashMap<>();
        for (WxCpDepart d : departs) {
            voMap.put(d.getId(), toDeptVO(d));
        }
        List<WxCpDepartmentRespVO> roots = new ArrayList<>();
        for (WxCpDepartmentRespVO vo : voMap.values()) {
            Long parentId = vo.getParentId();
            WxCpDepartmentRespVO parent = parentId == null ? null : voMap.get(parentId);
            // æ‰¾ä¸åˆ°çˆ¶èŠ‚ç‚¹çš„ï¼ˆå«é¡¶çº§éƒ¨é—¨ï¼‰éƒ½ä½œä¸ºæ ¹è¿”å›žï¼Œé¿å…å› æ•°æ®ç¼ºå¤±å¯¼è‡´éƒ¨é—¨"消失"
            if (parent == null || parent == vo) {
                roots.add(vo);
            } else {
                parent.getChildren().add(vo);
            }
        }
        return roots;
    }
    @Override
    public List<WxCpMemberRespVO> getMemberList(Long departmentId, Boolean fetchChild) {
        Long deptId = departmentId != null ? departmentId : ROOT_DEPARTMENT_ID;
        boolean child = fetchChild == null || fetchChild;
        List<WxCpUser> users;
        try {
            users = wxCpService.getUserService().listSimpleByDepartment(deptId, child, null);
        } catch (Exception e) {
            log.error("[getMemberList][获取企业微信成员失败 deptId={} fetchChild={}]", deptId, child, e);
            throw exception(WXCP_API_ERROR);
        }
        // éƒ¨é—¨åæ˜ å°„,用于把成员的部门ID翻译成可读名称
        Map<Long, String> deptNameMap = new LinkedHashMap<>();
        for (WxCpDepart d : listDeparts()) {
            deptNameMap.put(d.getId(), d.getName());
        }
        return users.stream().map(u -> toMemberVO(u, deptNameMap)).toList();
    }
    /**
     * èŽ·å–å…¨éƒ¨éƒ¨é—¨ï¼ˆå¹³é“ºï¼‰ã€‚ä¼ä¸šå¾®ä¿¡ department/list ä¸ä¼  id æ—¶è¿”回全量。
     */
    private List<WxCpDepart> listDeparts() {
        try {
            List<WxCpDepart> departs = wxCpService.getDepartmentService().list(null);
            return departs != null ? departs : List.of();
        } catch (Exception e) {
            log.error("[listDeparts][获取企业微信部门失败]", e);
            throw exception(WXCP_API_ERROR);
        }
    }
    private WxCpDepartmentRespVO toDeptVO(WxCpDepart depart) {
        WxCpDepartmentRespVO vo = new WxCpDepartmentRespVO();
        vo.setId(depart.getId());
        vo.setName(depart.getName());
        vo.setParentId(depart.getParentId());
        vo.setOrder(depart.getOrder());
        return vo;
    }
    private WxCpMemberRespVO toMemberVO(WxCpUser user, Map<Long, String> deptNameMap) {
        WxCpMemberRespVO vo = new WxCpMemberRespVO();
        vo.setUserId(user.getUserId());
        vo.setName(user.getName());
        vo.setMobile(user.getMobile());
        vo.setEmail(user.getEmail());
        vo.setAvatar(user.getAvatar());
        // ä¼˜å…ˆå– position,老接口可能只返回 positions æ•°ç»„
        String position = user.getPosition();
        if (position == null && user.getPositions() != null && user.getPositions().length > 0) {
            position = String.join("、", user.getPositions());
        }
        vo.setPosition(position);
        vo.setStatus(user.getStatus());
        // Gender.getCode() è¿”回基本类型 int,不能与 null ç»„成三元表达式,这里单独判断
        if (user.getGender() != null) {
            vo.setGender(user.getGender().getCode());
        }
        Long[] deptIds = user.getDepartIds();
        if (deptIds != null && deptIds.length > 0) {
            List<Long> ids = Arrays.asList(deptIds);
            vo.setDepartmentIds(ids);
            vo.setDepartmentNames(String.join("、", ids.stream()
                    .map(id -> deptNameMap.getOrDefault(id, "部门" + id))
                    .toList()));
        }
        return vo;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
package cn.iocoder.yudao.module.wxcp.service.notice;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import jakarta.validation.Valid;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface WxCpNoticeService {
    /**
     * åˆ›å»ºé€šçŸ¥ï¼ˆåˆå§‹ä¸ºè‰ç¨¿çŠ¶æ€ï¼‰
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @return é€šçŸ¥ç¼–号
     */
    Long createNotice(@Valid WxCpNoticeSaveReqVO createReqVO);
    /**
     * ä¿®æ”¹é€šçŸ¥
     *
     * @param updateReqVO ä¿®æ”¹ä¿¡æ¯
     */
    void updateNotice(@Valid WxCpNoticeSaveReqVO updateReqVO);
    /**
     * åˆ é™¤é€šçŸ¥
     *
     * @param id é€šçŸ¥ç¼–号
     */
    void deleteNotice(Long id);
    /**
     * èŽ·å¾—é€šçŸ¥
     *
     * @param id é€šçŸ¥ç¼–号
     * @return é€šçŸ¥
     */
    WxCpNoticeDO getNotice(Long id);
    /**
     * èŽ·å¾—é€šçŸ¥åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return é€šçŸ¥åˆ†é¡µ
     */
    PageResult<WxCpNoticeDO> getNoticePage(@Valid WxCpNoticePageReqVO pageReqVO);
    /**
     * å‘布通知(草稿 -&gt; å·²å‘布)
     *
     * @param id é€šçŸ¥ç¼–号
     */
    void publishNotice(Long id);
    /**
     * æ’¤å›žé€šçŸ¥
     *
     * @param id é€šçŸ¥ç¼–号
     */
    void revokeNotice(Long id);
    /**
     * æŽ¨é€é€šçŸ¥ç»™ä¼ä¸šå¾®ä¿¡æˆå‘˜
     *
     * @param pushReqVO æŽ¨é€ä¿¡æ¯
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushNotice(@Valid WxCpPushReqVO pushReqVO);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
package cn.iocoder.yudao.module.wxcp.service.notice;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import cn.iocoder.yudao.module.wxcp.dal.mysql.notice.WxCpNoticeMapper;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.service.push.WxCpMessagePushService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.*;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class WxCpNoticeServiceImpl implements WxCpNoticeService {
    /**
     * ä¼ä¸šå¾®ä¿¡æ–‡æœ¬æ¶ˆæ¯ä¸Šé™çº¦ 2048 å­—节,中文按 3 å­—节算。
     * è¿™é‡ŒæŒ‰å­—符数留出足够余量,超出部分截断并在末尾提示,避免整条推送失败。
     */
    private static final int MAX_PUSH_CONTENT_LENGTH = 600;
    @Resource
    private WxCpNoticeMapper wxcpNoticeMapper;
    @Resource
    private WxCpMessagePushService messagePushService;
    @Override
    public Long createNotice(WxCpNoticeSaveReqVO createReqVO) {
        WxCpNoticeDO notice = BeanUtils.toBean(createReqVO, WxCpNoticeDO.class);
        notice.setStatus(WxCpNoticeStatusEnum.DRAFT.getStatus());
        notice.setPushStatus(0);
        // æŽ¨é€èŒƒå›´é€‰å¡«ï¼Œä¸ä¼ é»˜è®¤å…¨å‘˜ï¼›æŽ¨é€æ—¶è¿˜èƒ½åœ¨å¼¹çª—里改
        if (notice.getPushScope() == null) {
            notice.setPushScope(WxCpPushScopeEnum.ALL.getScope());
        }
        wxcpNoticeMapper.insert(notice);
        return notice.getId();
    }
    @Override
    public void updateNotice(WxCpNoticeSaveReqVO updateReqVO) {
        WxCpNoticeDO notice = validateNoticeExists(updateReqVO.getId());
        if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.REVOKED.getStatus())) {
            throw exception(NOTICE_CANNOT_UPDATE);
        }
        WxCpNoticeDO updateObj = BeanUtils.toBean(updateReqVO, WxCpNoticeDO.class);
        // çŠ¶æ€ä¸ŽæŽ¨é€ä¿¡æ¯ç”±ç³»ç»Ÿç»´æŠ¤ï¼Œä¸å…è®¸å‰ç«¯è¦†ç›–
        updateObj.setStatus(null);
        updateObj.setPushStatus(null);
        updateObj.setPushTime(null);
        wxcpNoticeMapper.updateById(updateObj);
    }
    @Override
    public void deleteNotice(Long id) {
        validateNoticeExists(id);
        wxcpNoticeMapper.deleteById(id);
    }
    @Override
    public WxCpNoticeDO getNotice(Long id) {
        return wxcpNoticeMapper.selectById(id);
    }
    @Override
    public PageResult<WxCpNoticeDO> getNoticePage(WxCpNoticePageReqVO pageReqVO) {
        return wxcpNoticeMapper.selectPage(pageReqVO);
    }
    @Override
    public void publishNotice(Long id) {
        WxCpNoticeDO notice = validateNoticeExists(id);
        if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.PUBLISHED.getStatus())) {
            return; // å·²å‘布,幂等返回
        }
        if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.REVOKED.getStatus())) {
            throw exception(NOTICE_STATUS_REVOKED);
        }
        wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
                .id(id).status(WxCpNoticeStatusEnum.PUBLISHED.getStatus()).build());
    }
    @Override
    public void revokeNotice(Long id) {
        validateNoticeExists(id);
        wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
                .id(id).status(WxCpNoticeStatusEnum.REVOKED.getStatus()).build());
    }
    @Override
    public WxCpPushResultVO pushNotice(WxCpPushReqVO pushReqVO) {
        WxCpNoticeDO notice = validateNoticeExists(pushReqVO.getId());
        if (!Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.PUBLISHED.getStatus())) {
            throw exception(NOTICE_STATUS_NOT_PUBLISHED);
        }
        Integer scope = pushReqVO.getPushScope() != null ? pushReqVO.getPushScope() : notice.getPushScope();
        String target = StrUtil.isNotBlank(pushReqVO.getPushTarget())
                ? pushReqVO.getPushTarget() : notice.getPushTarget();
        if (scope == null) {
            scope = WxCpPushScopeEnum.ALL.getScope();
        }
        // é€šçŸ¥ç”¨çº¯æ–‡æœ¬æŽ¨é€ï¼šæ²¡æœ‰å¯è·³è½¬çš„详情页,而 textcard ç±»åž‹å¼ºåˆ¶è¦æ±‚ url
        WxCpPushResultVO result = messagePushService.pushText(
                buildPushContent(notice), scope, target);
        if (Boolean.TRUE.equals(result.getSuccess())) {
            wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
                    .id(notice.getId())
                    .pushScope(scope)
                    .pushTarget(target)
                    .pushStatus(1)
                    .pushTime(LocalDateTime.now())
                    .build());
        }
        return result;
    }
    /**
     * æ‹¼è£…推送正文:标题加粗样式用不了,改用【】包裹;过长则截断并提示
     */
    private String buildPushContent(WxCpNoticeDO notice) {
        String body = StrUtil.blankToDefault(notice.getContent(), "");
        String content = "【" + notice.getTitle() + "】\n" + body;
        if (content.length() > MAX_PUSH_CONTENT_LENGTH) {
            content = content.substring(0, MAX_PUSH_CONTENT_LENGTH)
                    + "\n……(内容过长已截断,请到系统内查看完整通知)";
        }
        return content;
    }
    private WxCpNoticeDO validateNoticeExists(Long id) {
        if (id == null) {
            throw exception(NOTICE_NOT_EXISTS);
        }
        WxCpNoticeDO notice = wxcpNoticeMapper.selectById(id);
        if (notice == null) {
            throw exception(NOTICE_NOT_EXISTS);
        }
        return notice;
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notify/WxCpNotifyService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
package cn.iocoder.yudao.module.wxcp.service.notify;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialUserBindDO;
import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.social.SocialUserBindMapper;
import cn.iocoder.yudao.module.system.dal.mysql.social.SocialUserMapper;
import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.message.WxCpMessage;
import me.chanjar.weixin.cp.bean.message.WxCpMessageSendResult;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
 * ä¼ä¸šå¾®ä¿¡æ¶ˆæ¯é€šçŸ¥æœåŠ¡
 *
 * @author è¶…级管理员
 */
@Service
@Slf4j
public class WxCpNotifyService {
    @Resource
    private WxCpService wxCpService;
    @Resource
    private SocialUserBindMapper socialUserBindMapper;
    @Resource
    private SocialUserMapper socialUserMapper;
    /**
     * å‘送登录通知消息到企业微信
     *
     * @param userId     ç³»ç»Ÿç”¨æˆ· ID
     * @param username   ç”¨æˆ·ç™»å½•名
     * @param loginType  ç™»å½•方式(如:账号密码、短信、社交)
     */
    @Async
    public void sendLoginNotification(Long userId, String username, String loginType) {
        try {
            // 1. æŸ¥è¯¢ç”¨æˆ·ç»‘定的企业微信
            String wxUserId = getWxUserId(userId);
            if (wxUserId == null) {
                log.info("[sendLoginNotification][用户({})未绑定企业微信,跳过通知]", userId);
                return;
            }
            // 2. æž„造文本消息
            // æ³¨æ„ï¼šä¸èƒ½ç”¨ TEXTCARD â€”— ä¼ä¸šå¾®ä¿¡è¦æ±‚ textcard çš„ url å¿…填,
            // ç•™ç©ºä¼šè¿”回 41010 missing url,导致通知全部发送失败
            String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            WxCpMessage message = WxCpMessage.TEXT()
                    .toUser(wxUserId)
                    .content(String.format(
                            "【登录通知】您的账号于 %s é€šè¿‡ã€Œ%s」方式登录系统。\n如非本人操作,请立即修改密码。",
                            time, loginType))
                    .build();
            // 3. å‘送消息
            WxCpMessageSendResult result = wxCpService.getMessageService().send(message);
            if (result.getErrCode() == 0) {
                log.info("[sendLoginNotification][企业微信登录通知发送成功 userId={} wxUserId={}]", userId, wxUserId);
            } else {
                log.warn("[sendLoginNotification][企业微信登录通知发送失败 userId={} wxUserId={} errCode={} errMsg={}]",
                        userId, wxUserId, result.getErrCode(), result.getErrMsg());
            }
        } catch (Exception e) {
            log.error("[sendLoginNotification][企业微信登录通知发送异常 userId={}]", userId, e);
        }
    }
    /**
     * å‘送普通文本消息到企业微信
     *
     * @param userId  ç³»ç»Ÿç”¨æˆ· ID
     * @param content æ¶ˆæ¯å†…容
     */
    @Async
    public void sendTextMessage(Long userId, String content) {
        try {
            String wxUserId = getWxUserId(userId);
            if (wxUserId == null) {
                return;
            }
            WxCpMessage message = WxCpMessage.TEXT()
                    .toUser(wxUserId)
                    .content(content)
                    .build();
            WxCpMessageSendResult result = wxCpService.getMessageService().send(message);
            if (result.getErrCode() == 0) {
                log.info("[sendTextMessage][企业微信文本消息发送成功 userId={}]", userId);
            } else {
                log.warn("[sendTextMessage][企业微信文本消息发送失败 userId={} errCode={} errMsg={}]",
                        userId, result.getErrCode(), result.getErrMsg());
            }
        } catch (Exception e) {
            log.error("[sendTextMessage][企业微信文本消息发送异常 userId={}]", userId, e);
        }
    }
    /**
     * æ ¹æ®ç³»ç»Ÿç”¨æˆ· ID èŽ·å–ä¼ä¸šå¾®ä¿¡ UserId
     *
     * @param userId ç³»ç»Ÿç”¨æˆ· ID
     * @return ä¼ä¸šå¾®ä¿¡ UserId(即 openid)
     */
    private String getWxUserId(Long userId) {
        // 1. æŸ¥ç»‘定关系
        SocialUserBindDO bind = socialUserBindMapper.selectByUserIdAndUserTypeAndSocialType(
                userId, UserTypeEnum.ADMIN.getValue(), SocialTypeEnum.WECHAT_ENTERPRISE.getType());
        if (bind == null) {
            return null;
        }
        // 2. æŸ¥ç¤¾äº¤ç”¨æˆ·ä¿¡æ¯
        SocialUserDO socialUser = socialUserMapper.selectById(bind.getSocialUserId());
        if (socialUser == null) {
            return null;
        }
        return socialUser.getOpenid();
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.wxcp.service.push;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
/**
 * ä¼ä¸šå¾®ä¿¡æ¶ˆæ¯æŽ¨é€ Service æŽ¥å£
 *
 * æŠ½å‡ºæ¥ä¾›æ´»åŠ¨ã€é€šçŸ¥ç­‰ä¸šåŠ¡å¤ç”¨ï¼Œé¿å…å„å¤„é‡å¤æ‹¼è£…æŽ¥æ”¶è€…ä¸Žé”™è¯¯å¤„ç†
 *
 * @author è¶…级管理员
 */
public interface WxCpMessagePushService {
    /**
     * æŽ¨é€æ–‡æœ¬å¡ç‰‡æ¶ˆæ¯
     *
     * @param title       å¡ç‰‡æ ‡é¢˜
     * @param description å¡ç‰‡æ­£æ–‡
     * @param url         ç‚¹å‡»æŒ‰é’®è·³è½¬åœ°å€ï¼Œtextcard ç±»åž‹å¿…å¡«
     * @param btnTxt      æŒ‰é’®æ–‡æ¡ˆ
     * @param scope       æŽ¨é€èŒƒå›´ï¼Œè§ {@link cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum}
     * @param target      æŽ¨é€ç›®æ ‡ï¼›èŒƒå›´æ˜¯å…¨å‘˜æ—¶å¯ä¼ ç©º
     * @return æŽ¨é€ç»“果(不抛异常,失败信息通过返回值给出)
     */
    WxCpPushResultVO pushCard(String title, String description, String url,
                             String btnTxt, Integer scope, String target);
    /**
     * æŽ¨é€çº¯æ–‡æœ¬æ¶ˆæ¯
     *
     * @param content æ–‡æœ¬å†…容
     * @param scope   æŽ¨é€èŒƒå›´
     * @param target  æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushText(String content, Integer scope, String target);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,118 @@
package cn.iocoder.yudao.module.wxcp.service.push;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.framework.config.WxCpProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.message.WxCpMessage;
import me.chanjar.weixin.cp.bean.message.WxCpMessageSendResult;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.ACTIVITY_PUSH_TARGET_EMPTY;
/**
 * ä¼ä¸šå¾®ä¿¡æ¶ˆæ¯æŽ¨é€ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class WxCpMessagePushServiceImpl implements WxCpMessagePushService {
    @Resource
    private WxCpService wxCpService;
    @Resource
    private WxCpProperties wxCpProperties;
    @Override
    public WxCpPushResultVO pushCard(String title, String description, String url,
                                     String btnTxt, Integer scope, String target) {
        // textcard ç±»åž‹çš„ url å¿…填,留空企业微信会返回 41010
        if (StrUtil.isBlank(url)) {
            throw new IllegalArgumentException("推送卡片消息时 url ä¸èƒ½ä¸ºç©º");
        }
        WxCpMessage message = WxCpMessage.TEXTCARD()
                .agentId(agentId())
                .title(title)
                .description(description)
                .url(url)
                .btnTxt(StrUtil.blankToDefault(btnTxt, "查看详情"))
                .build();
        return doSend(message, scope, target, "card");
    }
    @Override
    public WxCpPushResultVO pushText(String content, Integer scope, String target) {
        WxCpMessage message = WxCpMessage.TEXT()
                .agentId(agentId())
                .content(content)
                .build();
        return doSend(message, scope, target, "text");
    }
    /**
     * ç»Ÿä¸€å¤„理接收者解析、发送与错误包装
     */
    private WxCpPushResultVO doSend(WxCpMessage message, Integer scope, String target, String type) {
        Recipients recipients = resolveRecipients(scope, target);
        message.setToUser(recipients.toUser);
        message.setToParty(recipients.toParty);
        WxCpMessageSendResult result;
        try {
            result = wxCpService.getMessageService().send(message);
        } catch (Exception e) {
            // ç½‘络/配置异常不向上抛,转成可读结果,避免前端拿到 500 ä¸çŸ¥é“原因
            log.error("[doSend][企业微信消息推送异常 type={} toUser={} toParty={}]",
                    type, recipients.toUser, recipients.toParty, e);
            return new WxCpPushResultVO(false, null, "推送异常:" + e.getMessage(),
                    recipients.toUser, recipients.toParty);
        }
        if (result.getErrCode() != null && result.getErrCode() == 0) {
            log.info("[doSend][企业微信消息推送成功 type={} toUser={} toParty={}]",
                    type, recipients.toUser, recipients.toParty);
            return new WxCpPushResultVO(true, 0, "推送成功", recipients.toUser, recipients.toParty);
        }
        log.warn("[doSend][企业微信消息推送失败 type={} errCode={} errMsg={}]",
                type, result.getErrCode(), result.getErrMsg());
        return new WxCpPushResultVO(false, result.getErrCode(),
                "企业微信返回:" + result.getErrMsg(), recipients.toUser, recipients.toParty);
    }
    /**
     * è§£æžæŽ¨é€èŒƒå›´ä¸Žç›®æ ‡ï¼Œè½¬æˆä¼ä¸šå¾®ä¿¡è¦æ±‚çš„ | åˆ†é𔿠¼å¼
     */
    private Recipients resolveRecipients(Integer scope, String target) {
        int actualScope = scope != null ? scope : WxCpPushScopeEnum.ALL.getScope();
        if (Objects.equals(actualScope, WxCpPushScopeEnum.ALL.getScope())) {
            return new Recipients("@all", null);
        }
        if (StrUtil.isBlank(target)) {
            throw exception(ACTIVITY_PUSH_TARGET_EMPTY);
        }
        // åŒæ—¶å®¹å¿ä¸­è‹±æ–‡é€—号与竖线,避免配置时手误
        String normalized = target.replace(",", ",").replace(",", "|").replace(" ", "");
        if (Objects.equals(actualScope, WxCpPushScopeEnum.PARTY.getScope())) {
            return new Recipients(null, normalized);
        }
        return new Recipients(normalized, null);
    }
    private Integer agentId() {
        return StrUtil.isBlank(wxCpProperties.getAgentId())
                ? null : Integer.valueOf(wxCpProperties.getAgentId());
    }
    private record Recipients(String toUser, String toParty) {
    }
}
yudao-server/pom.xml
@@ -171,6 +171,13 @@
            <version>${revision}</version>
        </dependency>
        <!-- ä¼ä¸šå¾®ä¿¡æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-wxcp</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- è´¢åŠ¡ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>