From 3ca5db95e015996977f5dd3b0c2ea998ba819922 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 23 Apr 2026 20:38:27 +0800 Subject: [PATCH] ♻️ refactor(im): 重命名 IM 模块中的多个类和文件以简化结构 --- yudao-module-im/pom.xml | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java | 5 +++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java | 43 +++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java | 19 +++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java | 15 +++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java | 30 ++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java | 42 ++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java | 27 +++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java | 24 ++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java | 19 +++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java | 22 ++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java | 22 ++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java | 26 ++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http | 18 ++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java | 26 ++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java | 39 +++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java | 27 +++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java | 21 +++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java | 39 +++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java | 31 +++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java | 4 ++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java | 6 ++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java | 41 +++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java | 46 ++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java | 14 ++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java | 25 +++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java | 44 ++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java | 37 +++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java | 39 +++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java | 42 ++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java | 6 ++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java | 22 ++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java | 4 ++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/package-info.java | 8 ++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 549 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java | 17 +++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java | 44 ++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java | 24 ++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java | 24 ++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java | 28 ++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java | 32 ++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java | 24 ++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java | 18 ++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java | 16 ++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java | 36 ++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java | 330 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 518 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 1063 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 356 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/resources/application-unit-test.yaml | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/test/resources/sql/clean.sql | 5 +++++ yudao-module-im/src/test/resources/sql/create_tables.sql | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-api/pom.xml | 26 -------------------------- yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java | 5 ----- yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java | 37 ------------------------------------- yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java | 72 ------------------------------------------------------------------------ yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java | 39 --------------------------------------- yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java | 37 ------------------------------------- yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java | 42 ------------------------------------------ yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java | 57 --------------------------------------------------------- yudao-module-im/yudao-module-im-biz/pom.xml | 73 ------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java | 108 ------------------------------------------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java | 43 ------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java | 19 ------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java | 107 ----------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java | 86 -------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java | 15 --------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java | 30 ------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java | 42 ------------------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java | 27 --------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java | 24 ------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java | 19 ------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java | 22 ---------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java | 22 ---------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java | 50 -------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java | 26 -------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java | 83 ----------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http | 18 ------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java | 73 ------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java | 26 -------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java | 52 ---------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java | 39 --------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java | 27 --------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java | 21 --------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java | 39 --------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java | 31 ------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java | 70 ---------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java | 74 -------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java | 71 ----------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java | 109 ------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java | 72 ------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java | 41 ----------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java | 46 ---------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java | 14 -------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java | 81 --------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 117 --------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java | 83 ----------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java | 25 ------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java | 44 -------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java | 102 ------------------------------------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java | 6 ------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java | 22 ---------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java | 4 ---- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java | 88 ---------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java | 183 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java | 140 -------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java | 212 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java | 125 ----------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 291 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java | 116 -------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 549 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java | 72 ------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 182 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java | 17 ----------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java | 77 ----------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java | 44 -------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java | 106 ---------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java | 179 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java | 147 --------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java | 24 ------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java | 24 ------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java | 28 ---------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java | 32 -------------------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java | 24 ------------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java | 18 ------------------ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java | 16 ---------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java | 36 ------------------------------------ yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java | 127 ------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 287 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java | 198 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java | 263 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java | 330 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 518 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 1063 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 356 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java | 97 ------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java | 173 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/resources/application-unit-test.yaml | 49 ------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql | 5 ----- yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql | 94 ---------------------------------------------------------------------------------------------- yudao-server/pom.xml | 2 +- 178 files changed, 8648 insertions(+), 8723 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/package-info.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java create mode 100644 yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java create mode 100644 yudao-module-im/src/test/resources/application-unit-test.yaml create mode 100644 yudao-module-im/src/test/resources/sql/clean.sql create mode 100644 yudao-module-im/src/test/resources/sql/create_tables.sql delete mode 100644 yudao-module-im/yudao-module-im-api/pom.xml delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java delete mode 100644 yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java delete mode 100644 yudao-module-im/yudao-module-im-biz/pom.xml delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/resources/application-unit-test.yaml delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql delete mode 100644 yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql diff --git a/yudao-module-im/pom.xml b/yudao-module-im/pom.xml index c3e5327..cbb3b6d 100644 --- a/yudao-module-im/pom.xml +++ b/yudao-module-im/pom.xml @@ -7,24 +7,67 @@ yudao ${revision} - - yudao-module-im-api - yudao-module-im-biz - 4.0.0 yudao-module-im - pom + jar ${project.artifactId} - - im 模块,主要提供能力: - 1. 通讯能力,例如:消息发送、消息接收、消息撤回、消息已读等。 - 2. 通讯会话,例如:单聊、群聊、聊天室等。 - 3. 通讯消息,例如:文本、图片、语音、视频、文件等。 - 4. 通讯消息的存储,例如:消息存储、消息索引、消息搜索等。 - 5. 通讯消息的推送,例如:消息推送、消息通知等。 - 6. 通讯消息的安全,例如:消息加密、消息签名等。 + im 模块,我们放即时通讯业务。 + 例如说:单聊、群聊、消息收发、消息撤回、消息已读等等 - \ No newline at end of file + + + cn.iocoder.boot + yudao-module-system + ${revision} + + + + cn.iocoder.boot + yudao-common + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + cn.iocoder.boot + yudao-spring-boot-starter-websocket + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-excel + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java new file mode 100644 index 0000000..4938097 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java @@ -0,0 +1,5 @@ +/** + * @author anhaohao + * @since 2024/3/9 下午8:59 + */ +package cn.iocoder.yudao.module.im.api; \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java new file mode 100644 index 0000000..146cb54 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java @@ -0,0 +1,108 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +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 jakarta.validation.constraints.NotNull; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.singleton; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 好友") +@RestController +@RequestMapping("/im/friend") +@Validated +public class ImFriendController { + + @Resource + private ImFriendService friendService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得当前登录用户的好友列表") + public CommonResult> getMyFriendList() { + List friends = friendService.getFriendList(getLoginUserId()); + return success(buildFriendRespVOList(friends)); + } + + @GetMapping("/get") + @Operation(summary = "获得好友详情") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult getFriend(@RequestParam("friendUserId") Long friendUserId) { + ImFriendDO friend = friendService.getFriend(getLoginUserId(), friendUserId); + return success(buildFriendRespVO(friend)); + } + + @PostMapping("/add") + @Operation(summary = "添加好友(双向建立关系)") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult addFriend( + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { + friendService.addFriend(getLoginUserId(), friendUserId); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除好友(双向软删除)") + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") + public CommonResult deleteFriend( + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { + friendService.deleteFriend(getLoginUserId(), friendUserId); + return success(true); + } + + @PutMapping("/update") + @Operation(summary = "更新好友信息(当前仅免打扰)") + public CommonResult updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) { + friendService.updateFriend(getLoginUserId(), reqVO); + return success(true); + } + + // ========== 私有方法:VO 组装 ========== + + private List buildFriendRespVOList(Collection friends) { + if (CollUtil.isEmpty(friends)) { + return Collections.emptyList(); + } + // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1 + Map userMap = adminUserApi.getUserMap( + convertList(friends, ImFriendDO::getFriendUserId)); + return convertList(friends, f -> { + ImFriendRespVO vo = BeanUtils.toBean(f, ImFriendRespVO.class); + MapUtils.findAndThen(userMap, f.getFriendUserId(), user -> + vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + return vo; + }); + } + + private ImFriendRespVO buildFriendRespVO(ImFriendDO friend) { + if (friend == null) { + return null; + } + return CollUtil.getFirst(buildFriendRespVOList(singleton(friend))); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java new file mode 100644 index 0000000..8e00c39 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * IM 好友 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IM 好友 Response VO") +@Data +public class ImFriendRespVO { + + @Schema(description = "关系记录编号", example = "1024") + private Long id; + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long friendUserId; + + @Schema(description = "是否免打扰", example = "false") + private Boolean muted; + + @Schema(description = "好友状态(0=正常,1=已删除)", example = "0") + private Integer status; + + @Schema(description = "添加好友时间") + private LocalDateTime addTime; + + @Schema(description = "删除好友时间") + private LocalDateTime deleteTime; + + // ========== 下面是聚合字段,方便前端显示 ========== + + @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道") + private String nickname; + + @Schema(description = "好友头像(实时聚合自 AdminUser)") + private String avatar; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java new file mode 100644 index 0000000..a44ae83 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IM 好友更新 Request VO") +@Data +public class ImFriendUpdateReqVO { + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "好友用户编号不能为空") + private Long friendUserId; + + @Schema(description = "是否免打扰", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "免打扰状态不能为空") + private Boolean muted; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java new file mode 100644 index 0000000..4867775 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.im.controller.admin.group; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupRespVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +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 java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 群") +@RestController +@RequestMapping("/im/group") +@Validated +public class ImGroupController { + + @Resource + private ImGroupService groupService; + + // ==================== 群的写操作 ==================== + + @PostMapping("/create") + @Operation(summary = "创建群") + @PreAuthorize("@ss.hasPermission('im:group:create')") + public CommonResult createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { + ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); + return success(BeanUtils.toBean(group, ImGroupRespVO.class)); + } + + @PutMapping("/update") + @Operation(summary = "更新群") + @PreAuthorize("@ss.hasPermission('im:group:update')") + public CommonResult updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { + ImGroupDO group = groupService.updateGroup(updateReqVO, getLoginUserId()); + return success(BeanUtils.toBean(group, ImGroupRespVO.class)); + } + + @DeleteMapping("/dissolve") + @Operation(summary = "解散群") + @Parameter(name = "id", description = "群编号", required = true) + @PreAuthorize("@ss.hasPermission('im:group:delete')") + public CommonResult dissolveGroup(@RequestParam("id") Long id) { + groupService.dissolveGroup(id, getLoginUserId()); + return success(true); + } + + // ==================== 群的读操作 ==================== + + @GetMapping("/get") + @Operation(summary = "获得群") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:group:query')") + public CommonResult getGroup(@RequestParam("id") Long id) { + ImGroupDO group = groupService.getGroup(id); + return success(BeanUtils.toBean(group, ImGroupRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "获得当前登录用户的群列表") + public CommonResult> getMyGroupList() { + List groups = groupService.getMyGroupList(getLoginUserId()); + return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); + } + + // ==================== 群成员的写操作 ==================== + + @PostMapping("/invite") + @Operation(summary = "邀请用户加入群") + @PreAuthorize("@ss.hasPermission('im:group:update')") + public CommonResult inviteGroupMember(@Valid @RequestBody ImGroupMemberInviteReqVO inviteReqVO) { + groupService.inviteGroupMember(getLoginUserId(), inviteReqVO); + return success(true); + } + + @DeleteMapping("/quit") + @Operation(summary = "退出群") + @Parameter(name = "groupId", description = "群编号", required = true) + @PreAuthorize("@ss.hasPermission('im:group:update')") + public CommonResult quitGroup(@RequestParam("groupId") Long groupId) { + groupService.quitGroup(groupId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/kicking") + @Operation(summary = "移除群成员") + @PreAuthorize("@ss.hasPermission('im:group:update')") + public CommonResult removeGroupMember(@Valid @RequestBody ImGroupMemberRemoveReqVO removeReqVO) { + groupService.removeGroupMember(getLoginUserId(), removeReqVO); + return success(true); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java new file mode 100644 index 0000000..c967166 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.im.controller.admin.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRespVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +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 java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; + +@Tag(name = "管理后台 - 群成员") +@RestController +@RequestMapping("/im/group-member") +@Validated +public class ImGroupMemberController { + + @Resource + private ImGroupMemberService groupMemberService; + + @Resource + private AdminUserApi adminUserApi; + + @PutMapping("/update") + @Operation(summary = "更新群成员") + @PreAuthorize("@ss.hasPermission('im:group-member:update')") + public CommonResult updateGroupMember(@Valid @RequestBody ImGroupMemberUpdateReqVO updateReqVO) { + groupMemberService.updateGroupMember(getLoginUserId(), updateReqVO); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得群成员") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:group-member:query')") + public CommonResult getGroupMember(@RequestParam("id") Long id) { + ImGroupMemberDO groupMember = groupMemberService.getGroupMember(id); + return success(BeanUtils.toBean(groupMember, ImGroupMemberRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "获得指定群的成员列表") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") + public CommonResult> getGroupMemberList(@RequestParam("groupId") Long groupId) { + // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) + // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 + List members = groupMemberService.getGroupMemberListByGroupId(groupId); + // 1.2 校验当前登录用户是否为群的有效成员,非成员不可查看 + Long loginUserId = getLoginUserId(); + if (CollUtil.findOne(members, m -> loginUserId.equals(m.getUserId()) + && CommonStatusEnum.ENABLE.getStatus().equals(m.getStatus())) == null) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 2.批量聚合 AdminUser 信息(昵称 / 头像) + Map userMap = adminUserApi.getUserMap( + convertList(members, ImGroupMemberDO::getUserId)); + return success(convertList(members, m -> { + ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); + MapUtils.findAndThen(userMap, m.getUserId(), user -> + vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + return vo; + })); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java new file mode 100644 index 0000000..37fa337 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Schema(description = "管理后台 - 群创建 Request VO") +@Data +public class ImGroupCreateReqVO { + + @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群") + @NotBlank(message = "群名称不能为空") + private String name; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java new file mode 100644 index 0000000..fdb6016 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +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 ImGroupPageReqVO extends PageParam { + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", example = "31460") + private Long ownerUserId; + + @Schema(description = "群公告") + private String notice; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java new file mode 100644 index 0000000..037daf0 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 群 Response VO") +@Data +public class ImGroupRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + private Long id; + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + private Long ownerUserId; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + + @Schema(description = "是否封禁") + private Boolean banned; + + @Schema(description = "封禁时间") + private LocalDateTime bannedTime; + + @Schema(description = "群状态", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "解散时间") + private LocalDateTime dissolvedTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java new file mode 100644 index 0000000..0275fd8 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import jakarta.validation.constraints.*; + +@Schema(description = "管理后台 - 群新增/修改 Request VO") +@Data +public class ImGroupSaveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + private Long id; + + @Schema(description = "群名称", example = "芋艿") + private String name; + + @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") + @NotNull(message = "群主用户编号不能为空") + private Long ownerUserId; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java new file mode 100644 index 0000000..d423d50 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群更新 Request VO") +@Data +public class ImGroupUpdateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") + @NotNull(message = "群编号不能为空") + private Long id; + + @Schema(description = "群名称", example = "芋道技术交流群") + private String name; + + @Schema(description = "群头像") + private String avatar; + + @Schema(description = "群公告") + private String notice; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java new file mode 100644 index 0000000..a486190 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群成员邀请 Request VO") +@Data +public class ImGroupMemberCreateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + @NotNull(message = "用户编号不能为空") + private Long userId; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java new file mode 100644 index 0000000..12cd09d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 群成员邀请 Request VO") +@Data +public class ImGroupMemberInviteReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "被邀请的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") + @NotEmpty(message = "被邀请的用户编号列表不能为空") + private List memberUserIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java new file mode 100644 index 0000000..9862b12 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 群成员移除 Request VO") +@Data +public class ImGroupMemberRemoveReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "被移除的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") + @NotEmpty(message = "被移除的用户编号列表不能为空") + private List memberUserIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java new file mode 100644 index 0000000..1827e29 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 群成员 Response VO") +@Data +public class ImGroupMemberRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17071") + private Long id; + + @Schema(description = "群编号", example = "13279") + private Long groupId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") + private Long userId; + + @Schema(description = "组内显示名", example = "芋艿") + private String displayUserName; + + @Schema(description = "群显示备注", example = "核心群") + private String displayGroupName; + + @Schema(description = "是否免打扰") + private Boolean muted; + + @Schema(description = "成员状态", example = "0") + private Integer status; + + @Schema(description = "入群时间") + private LocalDateTime joinTime; + + @Schema(description = "退群时间") + private LocalDateTime quitTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + // ========== 关联 AdminUser 的字段 ========== + + @Schema(description = "用户昵称", example = "芋道") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java new file mode 100644 index 0000000..3e31cb9 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.experimental.Accessors; + +@Schema(description = "管理后台 - 群成员更新 Request VO") +@Data +@Accessors(chain = true) +public class ImGroupMemberUpdateReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "群内昵称", example = "芋头") + private String displayUserName; + + @Schema(description = "群名备注", example = "公司群") + private String displayGroupName; + + @Schema(description = "是否免打扰") + private Boolean muted; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java new file mode 100644 index 0000000..26bffe7 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 群聊消息") +@RestController +@RequestMapping("/im/message/group") +@Validated +public class ImGroupMessageController { + + @Resource + private ImGroupMessageService groupMessageService; + + @PostMapping("/send") + @Operation(summary = "发送群聊消息") + public CommonResult sendGroupMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) { + ImGroupMessageDO message = groupMessageService.sendGroupMessage(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); + } + + @GetMapping("/pull") + @Operation(summary = "拉取群聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullGroupMessageList(@RequestParam("minId") Long minId, + @RequestParam("size") Integer size) { + List messages = groupMessageService.pullGroupMessageList(getLoginUserId(), minId, size); + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); + } + + @PutMapping("/read") + @Operation(summary = "标记群聊消息已读") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1") + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100") + public CommonResult readGroupMessages(@RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + groupMessageService.readGroupMessages(getLoginUserId(), groupId, messageId); + return success(true); + } + + @DeleteMapping("/recall") + @Operation(summary = "撤回群聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallGroupMessage(@RequestParam("id") Long id) { + ImGroupMessageDO message = groupMessageService.recallGroupMessage(getLoginUserId(), id); + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); + } + + @GetMapping("/read-users") + @Operation(summary = "获取群消息已读用户列表") + @Parameter(name = "groupId", description = "群编号", required = true, example = "1") + @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") + public CommonResult> getGroupReadUsers(@RequestParam("groupId") Long groupId, + @RequestParam("messageId") Long messageId) { + return success(groupMessageService.getGroupReadUsers(getLoginUserId(), groupId, messageId)); + } + + @GetMapping("/list") + @Operation(summary = "查询群聊历史消息") + public CommonResult> getGroupMessageList(@Valid ImGroupMessageListReqVO reqVO) { + List messages = groupMessageService.getGroupMessageList(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http new file mode 100644 index 0000000..83d1773 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http @@ -0,0 +1,18 @@ +### 请求 /send 接口 => 成功 +POST {{baseUrl}}/im/message/send +Authorization: Bearer {{token}} +Content-Type: application/json +tenant-id: {{adminTenentId}} + +{ + "clientMessageId": "123", + "receiverId": 1, + "conversationType": 1, + "contentType": 101, + "content": "你好1" +} + +### 请求 /pull 接口 => 成功 +GET {{baseUrl}}/im/message/pull?sequence=0&size=2 +Authorization: Bearer {{token}} +tenant-id: {{adminTenentId}} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java new file mode 100644 index 0000000..f6b25d6 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.im.controller.admin.message; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - IM 私聊消息") +@RestController +@RequestMapping("/im/message/private") +@Validated +public class ImPrivateMessageController { + + @Resource + private ImPrivateMessageService privateMessageService; + + @PostMapping("/send") + @Operation(summary = "发送私聊消息") + public CommonResult sendPrivateMessage( + @Valid @RequestBody ImPrivateMessageSendReqVO reqVO) { + ImPrivateMessageDO message = privateMessageService.sendPrivateMessage(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); + } + + @GetMapping("/pull") + @Operation(summary = "拉取私聊消息(增量)") + @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") + @Parameter(name = "size", description = "拉取数量", required = true, example = "100") + public CommonResult> pullPrivateMessageList(@RequestParam("minId") Long minId, + @RequestParam("size") Integer size) { + List messages = privateMessageService.pullPrivateMessageList(getLoginUserId(), minId, size); + return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); + } + + @PutMapping("/read") + @Operation(summary = "标记私聊消息已读") + @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2") + public CommonResult readPrivateMessages(@RequestParam("receiverId") Long receiverId) { + privateMessageService.readPrivateMessages(getLoginUserId(), receiverId); + return success(true); + } + + @DeleteMapping("/recall") + @Operation(summary = "撤回私聊消息") + @Parameter(name = "id", description = "消息编号", required = true, example = "1") + public CommonResult recallPrivateMessage(@RequestParam("id") Long id) { + ImPrivateMessageDO message = privateMessageService.recallPrivateMessage(getLoginUserId(), id); + return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "查询私聊历史消息") + public CommonResult> getPrivateMessageList(@Valid ImPrivateMessageListReqVO reqVO) { + List messages = privateMessageService.getPrivateMessageList(getLoginUserId(), reqVO); + return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java new file mode 100644 index 0000000..76aa958 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 群聊历史消息列表 Request VO") +@Data +public class ImGroupMessageListReqVO { + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") + private Long maxId; + + @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + @NotNull(message = "拉取数量不能为空") + @Min(value = 1, message = "拉取数量最小值为 1") + @Max(value = 200, message = "拉取数量最大值为 200") + private Integer limit; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java new file mode 100644 index 0000000..ee8a164 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 群聊消息 Response VO + */ +@Schema(description = "管理后台 - 群聊消息 Response VO") +@Data +public class ImGroupMessageRespVO { + + @Schema(description = "消息编号", example = "1") + private Long id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private Long senderId; + + @Schema(description = "群编号", example = "1") + private Long groupId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + + @Schema(description = "@目标用户编号列表", example = "[1,2,3]") + private List atUserIds; + + @Schema(description = "定向接收用户编号列表", example = "[1,2]") + private List receiverUserIds; + + @Schema(description = "回执状态", example = "0") + private Integer receiptStatus; + + @Schema(description = "已读人数(回执消息、且发送人为当前用户时有值)", example = "3") + private Integer readCount; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java new file mode 100644 index 0000000..2e493a9 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 群聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 群聊消息发送 Request VO") +@Data +public class ImGroupMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "群编号不能为空") + private Long groupId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + + @Schema(description = "@目标用户编号列表", example = "[1,2,3]") + private List atUserIds; + + @Schema(description = "是否需要回执", example = "false") + private Boolean receipt; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java new file mode 100644 index 0000000..c9259d9 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 私聊历史消息列表 Request VO") +@Data +public class ImPrivateMessageListReqVO { + + @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + + @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") + private Long maxId; + + @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + @NotNull(message = "拉取数量不能为空") + @Min(value = 1, message = "拉取数量最小值为 1") + @Max(value = 200, message = "拉取数量最大值为 200") + private Integer limit; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java new file mode 100644 index 0000000..b353e77 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import jakarta.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 私聊消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class ImPrivateMessagePageReqVO extends PageParam { + + @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java new file mode 100644 index 0000000..3484b12 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 私聊消息 Response VO + */ +@Schema(description = "管理后台 - 私聊消息 Response VO") +@Data +public class ImPrivateMessageRespVO { + + @Schema(description = "消息编号", example = "1") + private Long id; + + @Schema(description = "客户端消息编号", example = "uuid-xxx") + private String clientMessageId; + + @Schema(description = "发送人编号", example = "1") + private Long senderId; + + @Schema(description = "接收人编号", example = "2") + private Long receiverId; + + @Schema(description = "消息类型", example = "0") + private Integer type; + + @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") + private String content; + + @Schema(description = "消息状态", example = "0") + private Integer status; + + @Schema(description = "发送时间") + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java new file mode 100644 index 0000000..cff487a --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 私聊消息发送 Request VO + */ +@Schema(description = "管理后台 - 私聊消息发送 Request VO") +@Data +public class ImPrivateMessageSendReqVO { + + @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") + @NotEmpty(message = "客户端消息编号不能为空") + private String clientMessageId; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "接收人编号不能为空") + private Long receiverId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "消息类型不能为空") + private Integer type; + + @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") + @NotEmpty(message = "消息内容不能为空") + private String content; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java new file mode 100644 index 0000000..31a8695 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/app/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位 + */ +package cn.iocoder.yudao.module.im.controller.app; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java new file mode 100644 index 0000000..f2e831e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/package-info.java @@ -0,0 +1,6 @@ +/** + * 提供 RESTful API 给前端: + * 1. admin 包:提供给管理后台 yudao-ui-admin 前端项目 + * 2. app 包:提供给用户 APP yudao-ui-app 前端项目,它的 Controller 和 VO 都要添加 App 前缀,用于和管理后台进行区分 + */ +package cn.iocoder.yudao.module.im.controller; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java new file mode 100644 index 0000000..c61f0e1 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.friend; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 好友关系 DO + *

+ * 业务语义: + * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A) + * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除 + * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知 + * + * @author 芋道源码 + */ +@TableName("im_friend") +@KeySequence("im_friend_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImFriendDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 好友用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long friendUserId; + /** + * 是否免打扰 + */ + private Boolean muted; + /** + * 好友状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 添加好友时间 + */ + private LocalDateTime addTime; + /** + * 删除好友时间 + *

+ * 不为 null 时表示已删除 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java new file mode 100644 index 0000000..c304938 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 群信息 DO + * + * @author 芋道源码 + */ +@TableName("im_group") +@KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 群名称 + */ + private String name; + /** + * 群主用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long ownerUserId; + /** + * 群头像 + */ + private String avatar; + /** + * 群公告 + */ + private String notice; + /** + * 是否封禁 + */ + private Boolean banned; + /** + * 封禁原因 + */ + private String bannedReason; + /** + * 封禁时间 + */ + private LocalDateTime bannedTime; + /** + * 群状态 + *

+ * 枚举 {@link CommonStatusEnum} + * ENABLE = 正常,DISABLE = 已解散 + */ + private Integer status; + /** + * 解散时间 + */ + private LocalDateTime dissolvedTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java new file mode 100644 index 0000000..cd275f9 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.group; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 群成员 DO + * + * @author 芋道源码 + */ +@TableName("im_group_member") +@KeySequence("im_group_member_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupMemberDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 群编号 + *

+ * 关联 {@link ImGroupDO#getId()} + */ + private Long groupId; + /** + * 用户编号 + *

+ * 关联 AdminUserDO 的 id 字段 + */ + private Long userId; + /** + * 组内显示名 + */ + private String displayUserName; + /** + * 群显示备注 + */ + private String displayGroupName; + /** + * 是否免打扰 + */ + private Boolean muted; + /** + * 成员状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 入群时间 + */ + private LocalDateTime joinTime; + /** + * 退群时间 + */ + private LocalDateTime quitTime; + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java new file mode 100644 index 0000000..f6c3d60 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 DO + * + * @author 芋道源码 + */ +@TableName(value = "im_group_message", autoResultMap = true) +@KeySequence("im_group_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImGroupMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 群编号 + * + * 关联 {@link ImGroupDO#getId()} + */ + private Long groupId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + * + * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型) + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * 定向接收用户编号列表,以逗号分隔 + *

+ * 为空表示全员可见 + * + * 关联 AdminUserDO 的 id 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List receiverUserIds; + /** + * @ 目标用户编号列表,以逗号分隔 + * + * 关联 AdminUserDO 的 id 字段 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List atUserIds; + /** + * 回执状态 + *

+ * 枚举 {@link ImGroupMessageReceiptStatusEnum} + */ + private Integer receiptStatus; + + // ========== 非表字段 ========== + + /** + * 离线拉取等场景下回算的已读人数 + */ + @TableField(exist = false) + private Integer readCount; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java new file mode 100644 index 0000000..bb87939 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.message; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * IM 私聊消息 DO + * + * @author 芋道源码 + */ +@TableName("im_private_message") +@KeySequence("im_private_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImPrivateMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 客户端消息编号,用于发送幂等 + */ + private String clientMessageId; + /** + * 发送人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long senderId; + /** + * 接收人编号 + * + * 关联 AdminUserDO 的 id 字段 + */ + private Long receiverId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容,JSON 格式 + * + * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 + */ + private String content; + /** + * 消息状态 + *

+ * 枚举 {@link ImMessageStatusEnum} + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java new file mode 100644 index 0000000..4f6d1fc --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 敏感词 DO + * + * @author 芋道源码 + */ +@TableName("im_sensitive_word") +@KeySequence("im_sensitive_word_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImSensitiveWordDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 敏感词 + */ + private String word; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java new file mode 100644 index 0000000..dc716f0 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.im.dal.mysql.friend; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * IM 好友关系 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImFriendMapper extends BaseMapperX { + + default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .eq(ImFriendDO::getFriendUserId, friendUserId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .orderByDesc(ImFriendDO::getId)); + } + default List selectListByUserIdAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .eq(ImFriendDO::getStatus, status) + .orderByDesc(ImFriendDO::getId)); + } + + default List selectListByUserIdAndFriendUserIdsAndStatus(Long userId, + Collection friendUserIds, + Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImFriendDO::getUserId, userId) + .in(ImFriendDO::getFriendUserId, friendUserIds) + .eq(ImFriendDO::getStatus, status)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java new file mode 100644 index 0000000..8861658 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * IM 群 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMapper extends BaseMapperX { +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java new file mode 100644 index 0000000..0f23180 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * IM 群成员 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMemberMapper extends BaseMapperX { + + default List selectListByGroupId(Long groupId) { + return selectList(new LambdaQueryWrapperX().eq(ImGroupMemberDO::getGroupId, groupId)); + } + + default ImGroupMemberDO selectByGroupIdAndUserId(Long groupId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getUserId, userId)); + } + + default List selectListByGroupIdAndUserIds(Long groupId, Collection userIds) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .in(ImGroupMemberDO::getUserId, userIds)); + } + + default List selectListByGroupIdAndStatus(Long groupId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getStatus, status)); + } + + default List selectListByUserIdAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getUserId, userId) + .eq(ImGroupMemberDO::getStatus, status)); + } + + /** + * 查询用户已退群的成员记录 + *

+ * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。 + * + * @param userId 用户编号 + * @param minQuitTime 最早退群时间(含),可空 + * @return 已退群成员记录列表 + */ + default List selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getUserId, userId) + .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus()) + .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByGroupIdAndStatus(Long groupId, Integer oldStatus, ImGroupMemberDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .eq(ImGroupMemberDO::getStatus, oldStatus)); + } + + @SuppressWarnings("UnusedReturnValue") + default int updateByGroupIdAndUserIdsAndStatus(Long groupId, Collection userIds, + Integer oldStatus, ImGroupMemberDO updateObj) { + return update(updateObj, new LambdaQueryWrapperX() + .eq(ImGroupMemberDO::getGroupId, groupId) + .in(ImGroupMemberDO::getUserId, userIds) + .eq(ImGroupMemberDO::getStatus, oldStatus)); + } + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java new file mode 100644 index 0000000..b1f339f --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImGroupMessageMapper extends BaseMapperX { + + /** + * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用) + * + * @param groupIds 用户当前仍在群内的群编号列表 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) + */ + default List selectListByMinId(List groupIds, Long minId, + LocalDateTime minSendTime, Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.in("group_id", groupIds) + .gt("id", minId) + .gt("send_time", minSendTime) + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询"退群前"的离线消息(退群成员使用) + *

+ * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、 + * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 + * + * @param groupId 群编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含) + * @param quitTime 退群时间(含),仅返回退群当时已存在的消息 + * @param size 拉取数量 + * @return 消息列表(按 id 升序) + */ + default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId, + LocalDateTime minSendTime, + LocalDateTime quitTime, + Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.eq("group_id", groupId) + .gt("id", minId) + .gt("send_time", minSendTime) + .le("send_time", quitTime) + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询群聊历史消息(游标拉取) + * + * @param groupId 群编号 + * @param maxId 起始消息 id(不含),为空则从最新开始 + * @param limit 拉取数量 + * @param joinTime 入群时间,仅返回入群之后的消息 + * @return 消息列表(按 id 倒序) + */ + default List selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.eq("group_id", groupId) + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) + .lt(maxId != null, "id", maxId) + .ge(joinTime != null, "send_time", joinTime) + .orderByDesc("id"); + wrapper.limitN(limit); + return selectList(wrapper); + } + + default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getSenderId, senderId) + .eq(ImGroupMessageDO::getClientMessageId, clientMessageId)); + } + + /** + * 查询群内指定范围内待回执的消息 + *

+ * 仅在用户"已读位置前进"时调用,避免全量扫描: + * 只有位于 (minId, maxId] 范围内、且仍处于 PENDING 的回执消息可能因本次已读而状态变化。 + * + * @param groupId 群编号 + * @param minId 起始消息 id(不含,上一次已读位置) + * @param maxId 结束消息 id(含,本次已读位置) + * @return 待回执消息列表 + */ + default List selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) { + return selectList(new LambdaQueryWrapperX() + .eq(ImGroupMessageDO::getGroupId, groupId) + .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .gt(minId != null, ImGroupMessageDO::getId, minId) + .le(ImGroupMessageDO::getId, maxId) + .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus())); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java new file mode 100644 index 0000000..46c8630 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 私聊消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImPrivateMessageMapper extends BaseMapperX { + + /** + * 根据 minId + 时间窗口增量拉取私聊消息 + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 + * @param size 拉取数量 + * @return 消息列表 + */ + default List selectListByMinId(Long userId, Long minId, + LocalDateTime minSendTime, Integer size) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.and(w -> w.eq("sender_id", userId) + .or() + .eq("receiver_id", userId)) + .gt("id", minId) + .gt("send_time", minSendTime) + .orderByAsc("id"); + wrapper.limitN(size); + return selectList(wrapper); + } + + /** + * 查询私聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param receiverId 对方用户编号 + * @param maxId 起始消息 id(不含),为空则从最新开始 + * @param limit 拉取数量 + * @return 消息列表(按 id 倒序) + */ + default List selectHistoryList(Long userId, Long receiverId, Long maxId, Integer limit) { + QueryWrapperX wrapper = new QueryWrapperX<>(); + wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId) + .or() + .eq("sender_id", receiverId).eq("receiver_id", userId)) + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) + .lt(maxId != null, "id", maxId) + .orderByDesc("id"); + wrapper.limitN(limit); + return selectList(wrapper); + } + + default ImPrivateMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { + return selectOne(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); + } + + default List selectListBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImPrivateMessageDO::getSenderId, senderId) + .eq(ImPrivateMessageDO::getReceiverId, receiverId) + .eq(ImPrivateMessageDO::getStatus, status)); + } + + default void updateByIdsAndStatus(List ids, Integer whereStatus, ImPrivateMessageDO updateObj) { + update(updateObj, new LambdaQueryWrapperX() + .in(ImPrivateMessageDO::getId, ids) + .eq(ImPrivateMessageDO::getStatus, whereStatus)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java new file mode 100644 index 0000000..49b916d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.im.dal.mysql.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 敏感词 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImSensitiveWordMapper extends BaseMapperX { + + // DONE @AI:已改名为 selectListByStatus,status 作为参数 + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImSensitiveWordDO::getStatus, status)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java new file mode 100644 index 0000000..d2d7149 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.dal.redis; + + +/** + * IM Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface RedisKeyConstants { + + /** + * 群消息已读位置 + * KEY 格式: im:group:message:read:{groupId} + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) + */ + String GROUP_MESSAGE_READ = "im:group:message:read:%s"; + + /** + * 好友关系是否存在的缓存 + *

+ * KEY 格式:friend:{userId}_{friendUserId} + * VALUE 数据类型:Boolean + */ + String FRIEND = "friend"; + + /** + * 群信息缓存 + *

+ * KEY 格式:group:{groupId} + * VALUE 数据类型:ImGroupDO + */ + String GROUP = "group"; + + /** + * 群有效成员 userId 列表缓存(仅 ENABLE 状态) + *

+ * KEY 格式:group_member_ids:{groupId} + * VALUE 数据类型:List + *

+ * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 + */ + String GROUP_MEMBER_IDS = "group_member_ids"; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java new file mode 100644 index 0000000..ede9986 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.im.dal.redis.message; + +import cn.hutool.core.convert.Convert; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ; + +/** + * 群消息已读位置 Redis DAO + * + * @author 芋道源码 + */ +@Repository +public class GroupMessageReadRedisDAO { + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新用户在某群的最大已读消息编号 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @param maxMessageId 最大已读消息编号 + */ + public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) { + String key = formatKey(groupId); + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); + } + + /** + * 获取用户在某群的最大已读消息编号 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 最大已读消息编号,不存在则返回 null + */ + public Long getReadMaxMessageId(Long groupId, Long userId) { + String key = formatKey(groupId); + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); + return Convert.toLong(val); + } + + /** + * 获取某群所有用户的已读位置 + * + * @param groupId 群编号 + * @return userId → maxReadMessageId 映射 + */ + public Map getReadMaxMessageIdMap(Long groupId) { + String key = formatKey(groupId); + Map entries = stringRedisTemplate.opsForHash().entries(key); + // 转换为 Long → Long 的 Map + Map result = new HashMap<>(entries.size()); + entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString()))); + return result; + } + + /** + * 删除用户在某群的已读位置 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + public void deleteReadMaxMessageId(Long groupId, Long userId) { + String key = formatKey(groupId); + stringRedisTemplate.opsForHash().delete(key, userId.toString()); + } + + /** + * 批量删除用户在某群的已读位置 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { + String key = formatKey(groupId); + Object[] hashKeys = userIds.stream().map(String::valueOf).toArray(); + stringRedisTemplate.opsForHash().delete(key, hashKeys); + } + + /** + * 删除某群所有用户的已读位置(整个 Hash Key) + * + * @param groupId 群编号 + */ + public void deleteReadMaxMessageIdMap(Long groupId) { + String key = formatKey(groupId); + stringRedisTemplate.delete(key); + } + + private static String formatKey(Long groupId) { + return String.format(GROUP_MESSAGE_READ, groupId); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java new file mode 100644 index 0000000..db6d4ef --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.im.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * IM 错误码枚举类 + *

+ * im 系统,使用 1-040-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 消息 (1-040-300-000) ========== + ErrorCode MESSAGE_NOT_EXISTS = new ErrorCode(1_040_300_000, "消息不存在"); + ErrorCode MESSAGE_RECALL_DENIED = new ErrorCode(1_040_300_002, "只能撤回自己发送的消息"); + ErrorCode MESSAGE_ALREADY_RECALLED = new ErrorCode(1_040_300_003, "消息已撤回"); + ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送"); + ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); + ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); + + // ========== 群 (1-040-400-000) ========== + ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); + ErrorCode GROUP_BANNED = new ErrorCode(1_040_400_001, "群已被封禁"); + ErrorCode GROUP_DISSOLVED = new ErrorCode(1_040_400_002, "群已解散"); + ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); + + // ========== 群成员 (1-040-500-000) ========== + ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); + ErrorCode GROUP_OWNER_CANNOT_QUIT = new ErrorCode(1_040_500_003, "群主不能退出群聊,请先转让群主或解散群聊"); + ErrorCode GROUP_CANNOT_REMOVE_SELF = new ErrorCode(1_040_500_004, "不能将自己移出群聊"); + ErrorCode GROUP_MEMBER_EXCEED = new ErrorCode(1_040_500_005, "群聊人数不能超过 {} 人"); + ErrorCode GROUP_INVITE_NOT_FRIEND = new ErrorCode(1_040_500_006, "'{}' 不是您的好友,邀请失败"); + + // ========== 好友 (1-040-600-000) ========== + ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友"); + ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友"); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java new file mode 100644 index 0000000..924b96f --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.enums; + +/** + * IM 通用常量 + * + * @author 芋道源码 + */ +public interface ImCommonConstants { + + /** + * pull 最大拉取数量 + */ + int MESSAGE_MAX_PULL_SIZE = 1000; + + /** + * 群最大成员人数 + */ + int MAX_GROUP_MEMBER = 500; + + /** + * 消息撤回时间限制(分钟) + */ + int MESSAGE_RECALL_TIMEOUT_MINUTES = 5; + + /** + * 私聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取私聊离线消息时,仅返回最近 N 天内产生的消息, + * 超过该窗口的老消息不再主动推送(可通过历史消息接口按需倒翻)。 + */ + int MESSAGE_PRIVATE_PULL_MAX_DAYS = 30; + + /** + * 群聊离线消息最大拉取天数 + *

+ * 客户端通过 pull 接口增量拉取群聊离线消息时,仅返回最近 N 天内产生的消息; + * 退群前消息的补齐也以该窗口为基准(早于窗口的退群群不再扫描),避免老用户首次 + * 拉取时对历史退群群做大量查询。 + */ + int MESSAGE_GROUP_PULL_MAX_DAYS = 30; + + // ==================== 好友相关提示消息 ==================== + + /** + * 好友添加系统提示 + */ + String FRIEND_ADD_TIP_MESSAGE = "你们已成为好友,现在可以开始聊天了"; + /** + * 好友删除系统提示 + */ + String FRIEND_DELETE_TIP_MESSAGE = "你们的好友关系已被解除"; + + // ==================== 群相关提示消息 ==================== + + /** + * 群解散系统提示 + */ + String GROUP_DISSOLVE_TIP_MESSAGE = "\"{}\"解散了群聊"; + /** + * 退群系统提示 + */ + String GROUP_QUIT_TIP_MESSAGE = "你已退出群聊"; + /** + * 踢出群系统提示 + */ + String GROUP_REMOVE_TIP_MESSAGE = "你已被移出群聊"; + /** + * 邀请入群系统提示 + */ + String GROUP_INVITE_TIP_MESSAGE = "\"{}\"邀请\"{}\"加入了群聊"; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java new file mode 100644 index 0000000..256560c --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 群消息回执状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable { + + NO_RECEIPT(0, "不需要回执"), + PENDING(1, "待完成"), + DONE(2, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java new file mode 100644 index 0000000..79cb8d8 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IM 消息状态枚举 + *

+ * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3) + * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2) + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum ImMessageStatusEnum implements ArrayValuable { + + SENDING(-1, "发送中"), // 仅客户端使用 + UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态) + RECALL(2, "已撤回"), + READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现 + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java new file mode 100644 index 0000000..8fad3b1 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 消息类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImMessageTypeEnum implements ArrayValuable { + + TEXT(0, "文本"), // 对应 TextMessage 类 + IMAGE(1, "图片"), // 对应 ImageMessage 类 + FILE(2, "文件"), // 对应 FileMessage 类 + VOICE(3, "语音"), // 对应 AudioMessage 类 + VIDEO(4, "视频"), // 对应 VideoMessage 类 + + RECALL(10, "撤回"), // 对应 RecallMessage 类 + READ(11, "已读"), // 暂无 + RECEIPT(12, "回执"), // 暂无 + TIP_TEXT(21, "提示文本"), // 对应 TextMessage 类 + + // ==================== 非单聊、群聊存储的类型,100 开始 ==================== + + FRIEND_ADD(100, "好友添加"), // 暂无 + FRIEND_DELETE(101, "好友删除"), // 暂无 + FRIEND_UPDATE(102, "好友更新"), // 暂无(客户端收到后自行拉取) + + GROUP_CREATE(200, "群创建"), // 暂无 + GROUP_UPDATE(201, "群信息变更"), // 暂无 + GROUP_DELETE(202, "群删除"), // 暂无(解散/退群/踢出均用此类型) + GROUP_MEMBER_UPDATE(203, "群成员信息变更"); // 暂无(客户端收到后自行拉取) + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java new file mode 100644 index 0000000..61b6dae --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java @@ -0,0 +1,6 @@ +/** + * 属于 erp 模块的 framework 封装 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.im.framework; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java new file mode 100644 index 0000000..7e2b2ed --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * im 模块的 web 组件的 Configuration + */ +@Configuration(proxyBeanMethods = false) +public class ImWebConfiguration { + + /** + * im 模块的 API 分组 + */ + @Bean + public GroupedOpenApi imGroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("im"); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java new file mode 100644 index 0000000..e000c8e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * trade 模块的 web 配置 + */ +package cn.iocoder.yudao.module.im.framework.web; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/package-info.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/package-info.java new file mode 100644 index 0000000..9bf6bc3 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/package-info.java @@ -0,0 +1,8 @@ +/** + * im 模块,我们放即时通讯业务。 + * 例如说:单聊、群聊、消息收发、消息撤回、消息已读等等 + * + * 1. Controller URL:以 /im/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 im_ 开头,方便在数据库中区分 + */ +package cn.iocoder.yudao.module.im; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java new file mode 100644 index 0000000..9dce3bd --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; + +import java.util.Collection; +import java.util.List; + +/** + * IM 好友关系 Service 接口 + * + * @author 芋道源码 + */ +public interface ImFriendService { + + /** + * 判断两个用户是否是好友关系(未删除状态) + * + * @param userId 用户编号 + * @param friendUserId 好友用户编号 + * @return 是否好友 + */ + boolean isFriend(Long userId, Long friendUserId); + + /** + * 获得当前用户的好友列表(含已删除状态) + *

+ * 返回的列表里**包含已删除状态**,前端按 status 区分展示(已删除的置灰); + * 原因是本地已经建立会话的好友被单向删除后,前端仍需要展示头像/昵称。 + * + * @param userId 用户编号 + * @return 好友关系列表(含已删除的记录) + */ + List getFriendList(Long userId); + + /** + * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态) + * + * @param userId 用户编号 + * @param friendUserIds 需要校验的好友用户编号集合 + * @return 有效好友关系列表 + */ + List getActiveFriendList(Long userId, Collection friendUserIds); + + /** + * 查询一个好友关系记录(仅限本人视角) + * + * @param userId 当前登录用户编号 + * @param friendUserId 目标用户编号 + * @return 好友关系记录,不存在返回 null + */ + ImFriendDO getFriend(Long userId, Long friendUserId); + + /** + * 添加好友(双向绑定) + *

+ * 核心流程: + * 1. 校验:不允许添加自己;对方必须存在 + * 2. 双向插入/更新关系(若已存在则刷新为未删除) + * 3. 推送"你们已成为好友"系统提示(TODO) + * + * @param userId 当前登录用户编号 + * @param friendUserId 好友的用户编号 + */ + void addFriend(Long userId, Long friendUserId); + + /** + * 删除好友(双向软删除) + *

+ * 两边关系都标记为已删除状态。 + * + * @param userId 当前登录用户编号 + * @param friendUserId 好友的用户编号 + */ + void deleteFriend(Long userId, Long friendUserId); + + /** + * 更新好友信息(仅更新本人→对方的单边关系属性) + *

+ * 目前支持:免打扰(muted);后续可扩展备注等字段。 + * + * @param userId 当前登录用户编号 + * @param reqVO 更新请求 + */ + void updateFriend(Long userId, ImFriendUpdateReqVO reqVO); + + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java new file mode 100644 index 0000000..86b6a89 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java @@ -0,0 +1,183 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE; + +/** + * IM 好友关系 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImFriendServiceImpl implements ImFriendService { + + @Resource + private ImFriendMapper imFriendMapper; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private ImWebSocketService imWebSocketService; + @Resource + @Lazy + private ImPrivateMessageService privateMessageService; + + @Override + @Cacheable(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId", unless = "#result == null") + public boolean isFriend(Long userId, Long friendUserId) { + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + return friend != null && CommonStatusEnum.isEnable(friend.getStatus()); + } + + @Override + public List getFriendList(Long userId) { + return imFriendMapper.selectListByUserId(userId); + } + + @Override + public List getActiveFriendList(Long userId, Collection friendUserIds) { + return imFriendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds, + CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public ImFriendDO getFriend(Long userId, Long friendUserId) { + return imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + } + + @Override + public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) { + // 1. 校验好友关系存在 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId()); + if (friend == null) { + throw exception(FRIEND_NOT_FRIEND); + } + + // 2. 更新好友属性(目前仅免打扰) + imFriendMapper.updateById(new ImFriendDO().setId(friend.getId()).setMuted(reqVO.getMuted())); + + // 3. 推送好友更新通知(多端同步) + imWebSocketService.sendPrivateMessageAsync(userId, + ImPrivateMessageDTO.ofFriendUpdate(userId, reqVO.getFriendUserId())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void addFriend(Long userId, Long friendUserId) { + // 1.1 校验:不允许添加自己为好友 + if (Objects.equals(userId, friendUserId)) { + throw exception(FRIEND_ADD_SELF); + } + // 1.2 校验对方存在(禁用/不存在的账号不允许添加) + adminUserApi.validateUser(friendUserId); + + // 2. 双向绑定 + getSelf().addFriend0(userId, friendUserId); + getSelf().addFriend0(friendUserId, userId); + + // 3.1 插入 TIP_TEXT 系统提示消息并推送双方 + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_ADD_TIP_MESSAGE); + // 3.2 推送好友添加通知给双方 + imWebSocketService.sendPrivateMessageAsync(userId, + ImPrivateMessageDTO.ofFriendAdd(friendUserId, userId)); + imWebSocketService.sendPrivateMessageAsync(friendUserId, + ImPrivateMessageDTO.ofFriendAdd(userId, friendUserId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFriend(Long userId, Long friendUserId) { + // 1. 双向标记为 DISABLE + 记录 deleteTime + getSelf().deleteFriend0(userId, friendUserId); + getSelf().deleteFriend0(friendUserId, userId); + + // 2.1 插入 TIP_TEXT 系统提示消息并推送双方 + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE); + // 2.2 推送好友删除通知给双方 + imWebSocketService.sendPrivateMessageAsync(userId, + ImPrivateMessageDTO.ofFriendDelete(userId, friendUserId)); + imWebSocketService.sendPrivateMessageAsync(friendUserId, + ImPrivateMessageDTO.ofFriendDelete(friendUserId, userId)); + } + + /** + * 单向绑定好友关系: + * - 首次:新增记录(status=ENABLE,addTime=now) + * - 已存在且 ENABLE:无需重复操作 + * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime) + *

+ * 并发安全:依靠 im_friend 表的唯一索引 uk_im_friend_user_friend(user_id, friend_user_id) 保证幂等, + * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 + */ + @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId") + public void addFriend0(Long userId, Long friendUserId) { + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + // 情况一:已存在记录 → 恢复或跳过 + if (exists != null) { + if (CommonStatusEnum.isEnable(exists.getStatus())) { + return; + } + imFriendMapper.updateById(new ImFriendDO().setId(exists.getId()) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now())); + return; + } + // 情况二:新增好友关系 + ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) + .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build(); + try { + imFriendMapper.insert(friend); + } catch (DuplicateKeyException e) { + // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),直接忽略 + log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,忽略]", userId, friendUserId); + } + } + + /** + * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime) + */ + @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId") + public void deleteFriend0(Long userId, Long friendUserId) { + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) { + return; + } + imFriendMapper.updateById(new ImFriendDO().setId(exists.getId()) + .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now())); + } + + private ImFriendServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java new file mode 100644 index 0000000..fbd4bb7 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import jakarta.validation.Valid; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 群成员 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupMemberService { + + /** + * 获得群成员 + * + * @param id 编号 + * @return 群成员 + */ + ImGroupMemberDO getGroupMember(Long id); + + /** + * 根据群组 id 查询群成员(包含所有状态) + * + * @param groupId 群组id + * @return 群成员列表 + */ + List getGroupMemberListByGroupId(Long groupId); + + /** + * 根据群编号查询有效成员列表(仅 ENABLE 状态) + * + * @param groupId 群编号 + * @return 有效群成员列表 + */ + List getActiveGroupMemberListByGroupId(Long groupId); + + /** + * 根据群编号查询有效成员的 userId 列表(仅 ENABLE 状态) + *

+ * 相比 {@link #getActiveGroupMemberListByGroupId(Long)},只返回 userId,结果体积小,并带有 Redis 缓存。 + * 适用于"群消息推送目标"等只需要 userId 的场景; + * 需要 joinTime/quitTime/status 等字段做历史消息可见性判断时,仍应使用完整列表方法。 + * + * @param groupId 群编号 + * @return 有效群成员 userId 列表 + */ + List getActiveGroupMemberUserIdsByGroupId(Long groupId); + + /** + * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) + * + * @param userId 用户编号 + * @return 有效群成员记录列表 + */ + List getActiveGroupMemberListByUserId(Long userId); + + /** + * 查询用户已退群的群成员记录(DISABLE 状态) + * + * @param userId 用户编号,必传 + * @param minQuitTime 最早退群时间(含),可空 + * @return 已退群成员记录列表 + */ + List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime); + + /** + * 添加群成员(入群) + *

+ * 插入一条 ENABLE 状态的群成员记录,并设置 joinTime。 + * 可用于创建群时添加群主、邀请入群等场景。 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员记录 + */ + @SuppressWarnings("UnusedReturnValue") + ImGroupMemberDO addGroupMember(Long groupId, Long userId); + + /** + * 批量添加群成员(入群) + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void addGroupMembers(Long groupId, Collection userIds); + + /** + * 校验用户是否为群的有效成员 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群成员记录 + */ + ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId); + + /** + * 更新群成员信息(群内昵称、群名备注、免打扰等) + *

+ * 内部会校验用户是否为群的有效成员 + * + * @param userId 当前登录用户编号 + * @param updateReqVO 更新信息 + */ + void updateGroupMember(Long userId, @Valid ImGroupMemberUpdateReqVO updateReqVO); + + /** + * 移除指定群成员(设置为 DISABLE 状态) + *

+ * 用于退群、踢出场景 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + void removeGroupMember(Long groupId, Long userId); + + /** + * 批量移除指定群成员(设置为 DISABLE 状态) + *

+ * 用于批量踢出场景 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void removeGroupMembers(Long groupId, Collection userIds); + + /** + * 移除群的全部成员(设置为 DISABLE 状态) + *

+ * 用于群解散场景 + * + * @param groupId 群编号 + */ + void removeGroupMembersByGroupId(Long groupId); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java new file mode 100644 index 0000000..0b92f0d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java @@ -0,0 +1,212 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; + +/** + * 群成员 Service 实现类 + * + * @author 芋道源码 + */ +@Slf4j +@Service +@Validated +public class ImGroupMemberServiceImpl implements ImGroupMemberService { + + @Resource + private ImGroupMemberMapper groupMemberMapper; + + @Resource + private ImWebSocketService webSocketService; + + @Override + public ImGroupMemberDO getGroupMember(Long id) { + return groupMemberMapper.selectById(id); + } + + @Override + public List getGroupMemberListByGroupId(Long groupId) { + return groupMemberMapper.selectListByGroupId(groupId); + } + + @Override + public List getActiveGroupMemberListByGroupId(Long groupId) { + return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); + } + + /** + * 只缓存 userId 列表而非整个 {@link ImGroupMemberDO},理由: + *

    + *
  • 体积小:500 人群约 4KB,失效/序列化成本低;
  • + *
  • 失效面窄:仅 {@link #addGroupMember}/{@link #addGroupMembers}/ + * {@link #removeGroupMember}/{@link #removeGroupMembers}/{@link #removeGroupMembersByGroupId} + * 这类影响成员集合的写操作需要失效; + * {@link #updateGroupMember}(昵称/备注/免打扰)不修改集合成员,不需要失效。
  • + *
+ */ + @Override + @Cacheable(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public List getActiveGroupMemberUserIdsByGroupId(Long groupId) { + List members = groupMemberMapper.selectListByGroupIdAndStatus( + groupId, CommonStatusEnum.ENABLE.getStatus()); + return convertList(members, ImGroupMemberDO::getUserId); + } + + @Override + public List getActiveGroupMemberListByUserId(Long userId) { + return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) { + return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); + } + + /** + * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等, + * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 + */ + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { + LocalDateTime now = LocalDateTime.now(); + // 情况一:已存在记录 → 复活或跳过 + ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + if (exists != null) { + if (CommonStatusEnum.isDisable(exists.getStatus())) { + groupMemberMapper.updateById(new ImGroupMemberDO().setId(exists.getId()) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); + } + return groupMemberMapper.selectById(exists.getId()); + } + // 情况二:新增成员 + ImGroupMemberDO member = new ImGroupMemberDO() + .setGroupId(groupId).setUserId(userId) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now); + try { + groupMemberMapper.insert(member); + return member; + } catch (DuplicateKeyException e) { + // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),查询返回 + log.warn("[addGroupMember][groupId({}) userId({}) 并发插入冲突,查询返回]", groupId, userId); + return groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + } + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void addGroupMembers(Long groupId, Collection userIds) { + LocalDateTime now = LocalDateTime.now(); + // 1.1 查询已有记录(含已退群的 DISABLE 记录) + List existMembers = groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); + Map existMap = convertMap(existMembers, ImGroupMemberDO::getUserId); + // 1.2 分类:已有记录 → UPDATE,新成员 → INSERT + List inserts = new ArrayList<>(); + List updates = new ArrayList<>(); + for (Long userId : userIds) { + ImGroupMemberDO exist = existMap.get(userId); + if (exist == null) { + inserts.add(new ImGroupMemberDO().setGroupId(groupId).setUserId(userId) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); + } else if (CommonStatusEnum.DISABLE.getStatus().equals(exist.getStatus())) { + updates.add(new ImGroupMemberDO().setId(exist.getId()) + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); + } + } + + // 2.1 先做 update,update 没有并发冲突风险 + if (CollUtil.isNotEmpty(updates)) { + groupMemberMapper.updateBatch(updates); + } + // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId), + // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。 + if (CollUtil.isNotEmpty(inserts)) { + try { + groupMemberMapper.insertBatch(inserts); + } catch (DuplicateKeyException e) { + log.warn("[addGroupMembers][groupId({}) userIds({}) 批量插入冲突,降级为逐个处理]", groupId, userIds); + for (ImGroupMemberDO insert : inserts) { + addGroupMember(groupId, insert.getUserId()); + } + } + } + } + + @Override + public ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId) { + ImGroupMemberDO member = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); + if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + return member; + } + + @Override + public void updateGroupMember(Long userId, ImGroupMemberUpdateReqVO updateReqVO) { + // 1. 校验是群的有效成员 + ImGroupMemberDO member = validateMemberInGroup(updateReqVO.getGroupId(), userId); + + // 2. 更新群成员信息 + ImGroupMemberDO updateObj = BeanUtils.toBean(updateReqVO, ImGroupMemberDO.class) + .setId(member.getId()); + groupMemberMapper.updateById(updateObj); + + // 3. 推送群成员变更通知(多端同步,仅推给自己) + webSocketService.sendGroupMessageAsync(userId, + ImGroupMessageDTO.ofGroupMemberUpdate(userId, updateReqVO.getGroupId())); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMember(Long groupId, Long userId) { + // 1. 校验是群的有效成员 + ImGroupMemberDO member = validateMemberInGroup(groupId, userId); + // 2. 更新为退群状态 + groupMemberMapper.updateById(new ImGroupMemberDO().setId(member.getId()) + .setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMembers(Long groupId, Collection userIds) { + groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, + CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + + @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public void removeGroupMembersByGroupId(Long groupId) { + groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java new file mode 100644 index 0000000..b38991e --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 群 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupService { + + // ==================== 群的写操作 ==================== + + /** + * 创建群 + *

+ * 同时将当前登录用户设置为群主,并插入群主的群成员记录 + * + * @param createReqVO 创建信息 + * @param userId 当前登录用户编号(群主) + * @return 创建后的群信息 + */ + ImGroupDO createGroup(@Valid ImGroupCreateReqVO createReqVO, Long userId); + + /** + * 更新群信息 + * + * @param updateReqVO 更新信息 + * @param userId 当前登录用户编号 + * @return 更新后的群信息 + */ + ImGroupDO updateGroup(@Valid ImGroupUpdateReqVO updateReqVO, Long userId); + + /** + * 解散群 + *

+ * 仅群主可执行 + * + * @param id 群编号 + * @param userId 当前登录用户编号 + */ + void dissolveGroup(Long id, Long userId); + + // ==================== 群的读操作 ==================== + + /** + * 获得群 + * + * @param id 编号 + * @return 群 + */ + ImGroupDO getGroup(Long id); + + /** + * 校验群存在且未封禁、未解散 + * + * @param groupId 群编号 + * @return 群信息 + */ + ImGroupDO validateGroupExists(Long groupId); + + /** + * 校验当前用户是否为群主 + * + * @param groupId 群编号 + * @param userId 用户编号 + * @return 群信息 + */ + @SuppressWarnings("UnusedReturnValue") + ImGroupDO validateGroupOwner(Long groupId, Long userId); + + /** + * 获取指定用户的群列表 + *

+ * 返回用户当前仍有效的群,以及最近 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#MESSAGE_GROUP_PULL_MAX_DAYS} + * 天内退群的群 —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 + * + * @param userId 用户编号 + * @return 群列表 + */ + List getMyGroupList(Long userId); + + // ==================== 群成员的写操作 ==================== + // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, + // 保持 ImGroupMemberService 无 WebSocket 推送等外部依赖,职责更单一。 + + /** + * 邀请用户加入群 + *

+ * 群成员即可执行,支持批量。 + * 校验群人数上限,邀请后推送提示消息和群创建事件给被邀请人。 + * + * @param userId 当前登录用户编号 + * @param inviteReqVO 邀请信息 + */ + void inviteGroupMember(Long userId, @Valid ImGroupMemberInviteReqVO inviteReqVO); + + /** + * 退群 + *

+ * 群主不可退群(只能解散) + * + * @param groupId 群编号 + * @param userId 当前登录用户编号 + */ + void quitGroup(Long groupId, Long userId); + + /** + * 移除群成员(踢人) + *

+ * 仅群主可执行,且不能移除自己,支持批量 + * + * @param userId 当前登录用户编号(群主) + * @param removeReqVO 移除信息 + */ + void removeGroupMember(Long userId, @Valid ImGroupMemberRemoveReqVO removeReqVO); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java new file mode 100644 index 0000000..3801f4b --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -0,0 +1,291 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.bean.copier.CopyOptions; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; + +// TODO @芋艿:群主的调整,暂时不考虑。后续在弄; +/** + * 群 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImGroupServiceImpl implements ImGroupService { + + @Resource + private ImGroupMapper groupMapper; + + @Resource + @Lazy // 避免循环依赖 + private ImGroupMemberService groupMemberService; + @Resource + @Lazy // 避免循环依赖 + private ImGroupMessageService groupMessageService; + @Resource + private ImWebSocketService webSocketService; + + @Resource + private ImFriendService friendService; + + @Resource + private AdminUserApi adminUserApi; + + // ==================== 群的写操作 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupDO createGroup(ImGroupCreateReqVO createReqVO, Long userId) { + // 1. 插入群记录 + ImGroupDO group = BeanUtils.toBean(createReqVO, ImGroupDO.class) + .setOwnerUserId(userId).setStatus(CommonStatusEnum.ENABLE.getStatus()); + groupMapper.insert(group); + + // 2. 将群主插入为群成员 + groupMemberService.addGroupMember(group.getId(), userId); + + // 3. 推送群创建事件给群主(多端同步) + webSocketService.sendGroupMessageAsync(userId, + ImGroupMessageDTO.ofGroupCreate(userId, group.getId())); + return group; + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#updateReqVO.id") + @Transactional(rollbackFor = Exception.class) + public ImGroupDO updateGroup(ImGroupUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验群存在 + ImGroupDO group = validateGroupExists(updateReqVO.getId()); + // 1.2 校验操作人是群主 + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } + + // 2. 更新数据库 + ImGroupDO updateObj = BeanUtils.toBean(updateReqVO, ImGroupDO.class); + groupMapper.updateById(updateObj); + BeanUtil.copyProperties(updateReqVO, group, CopyOptions.create().ignoreNullValue()); + + // 3. 群信息的变更,推送给所有群成员 + List members = groupMemberService.getActiveGroupMemberListByGroupId(group.getId()); + Set memberUserIds = convertSet(members, ImGroupMemberDO::getUserId); + webSocketService.sendGroupMessageAsync(memberUserIds, + ImGroupMessageDTO.ofGroupUpdate(userId, group.getId())); + return group; + } + + @Override + @CacheEvict(cacheNames = GROUP, key = "#id") + @Transactional(rollbackFor = Exception.class) + public void dissolveGroup(Long id, Long userId) { + // 1. 校验群存在 + 当前用户是群主 + validateGroupOwner(id, userId); + + // 2.1 获取群成员列表(在移除之前,后续推送需要) + List members = groupMemberService.getActiveGroupMemberListByGroupId(id); + Set memberUserIds = convertSet(members, ImGroupMemberDO::getUserId); + // 2.2 更新群状态为已解散 + groupMapper.updateById(new ImGroupDO().setId(id) + .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); + // 2.3 移除全部群成员 + groupMemberService.removeGroupMembersByGroupId(id); + // 2.4 清理已读缓存 + groupMessageService.deleteReadMaxMessageIdMap(id); + + // 3.1 发送解散提示消息(TIP_TEXT)并推送 + AdminUserRespDTO user = adminUserApi.getUser(userId); + String tipContent = StrUtil.format(GROUP_DISSOLVE_TIP_MESSAGE, user.getNickname()); + groupMessageService.sendTipGroupMessage(userId, id, memberUserIds, tipContent); + // 3.2 推送群删除事件给所有原群成员 + webSocketService.sendGroupMessageAsync(memberUserIds, + ImGroupMessageDTO.ofGroupDelete(userId, id)); + } + + // ==================== 群成员的写操作 ==================== + + @Override + public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { + Long groupId = inviteReqVO.getGroupId(); + // 1.1 校验群存在 + 当前用户是群成员 + validateGroupExists(groupId); + groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 排除已在群中的用户 + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + activeMembers.forEach(member -> inviteReqVO.getMemberUserIds().remove(member.getUserId())); + if (CollUtil.isEmpty(inviteReqVO.getMemberUserIds())) { + return; + } + // 1.3 校验被邀请人都是当前用户的好友 + List memberUserIds = inviteReqVO.getMemberUserIds(); + List friends = friendService.getActiveFriendList(userId, memberUserIds); + Set friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); + Collection notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds); + if (CollUtil.isNotEmpty(notFriendUserIds)) { + throw exception(GROUP_INVITE_NOT_FRIEND, getUserNicknames(notFriendUserIds)); + } + // 1.4 校验群人数上限 + if (activeMembers.size() + memberUserIds.size() > MAX_GROUP_MEMBER) { + throw exception(GROUP_MEMBER_EXCEED, MAX_GROUP_MEMBER); + } + + // 2. 批量添加群成员 + groupMemberService.addGroupMembers(groupId, memberUserIds); + + // 3.1 推送群创建事件给被邀请人(让客户端新增群会话) + webSocketService.sendGroupMessageAsync(new HashSet<>(memberUserIds), + ImGroupMessageDTO.ofGroupCreate(userId, groupId)); + // 3.2 发送邀请提示消息给所有群成员 + String tipContent = StrUtil.format(GROUP_INVITE_TIP_MESSAGE, + getUserNicknames(List.of(userId)), getUserNicknames(memberUserIds)); + List allMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + Set allMemberUserIds = convertSet(allMembers, ImGroupMemberDO::getUserId); + groupMessageService.sendTipGroupMessage(userId, groupId, allMemberUserIds, tipContent); + } + + @Override + public void quitGroup(Long groupId, Long userId) { + // 1. 校验群存在 + ImGroupDO group = validateGroupExists(groupId); + // 2. 群主不可退群 + if (ObjUtil.equal(group.getOwnerUserId(), userId)) { + throw exception(GROUP_OWNER_CANNOT_QUIT); + } + + // 3.1 移除群成员 + groupMemberService.removeGroupMember(groupId, userId); + // 3.2 清理已读缓存 + groupMessageService.deleteReadMaxMessageId(groupId, userId); + + // 4.1 发送退群提示消息(TIP_TEXT) + groupMessageService.sendTipGroupMessage(userId, groupId, Set.of(userId), GROUP_QUIT_TIP_MESSAGE); + // 4.2 推送群删除事件 + webSocketService.sendGroupMessageAsync(Set.of(userId), + ImGroupMessageDTO.ofGroupDelete(userId, groupId)); + } + + @Override + public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { + Long groupId = removeReqVO.getGroupId(); + // 1. 校验群存在 + 当前用户是群主 + validateGroupOwner(groupId, userId); + // 2. 不能移除自己 + if (removeReqVO.getMemberUserIds().contains(userId)) { + throw exception(GROUP_CANNOT_REMOVE_SELF); + } + + // 3.1 批量移除群成员 + Set memberUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); + groupMemberService.removeGroupMembers(groupId, memberUserIds); + // 3.2 批量清理已读缓存 + groupMessageService.deleteReadMaxMessageIds(groupId, memberUserIds); + + // 4.1 发送踢出提示消息(TIP_TEXT) + groupMessageService.sendTipGroupMessage(userId, groupId, memberUserIds, GROUP_REMOVE_TIP_MESSAGE); + // 4.2 推送群删除事件 + webSocketService.sendGroupMessageAsync(memberUserIds, + ImGroupMessageDTO.ofGroupDelete(userId, groupId)); + } + + // ==================== 群的读操作 ==================== + + @Override + public ImGroupDO validateGroupExists(Long id) { + ImGroupDO group = getSelf().getGroup(id); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (Boolean.TRUE.equals(group.getBanned())) { + throw exception(GROUP_BANNED); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + return group; + } + + @Override + public ImGroupDO validateGroupOwner(Long groupId, Long userId) { + ImGroupDO group = validateGroupExists(groupId); + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } + return group; + } + + @Override + @Cacheable(cacheNames = GROUP, key = "#id", unless = "#result == null") + public ImGroupDO getGroup(Long id) { + return groupMapper.selectById(id); + } + + @Override + public List getMyGroupList(Long userId) { + // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) + List members = groupMemberService.getActiveGroupMemberListByUserId(userId); + // 1.2 再查最近 MESSAGE_GROUP_PULL_MAX_DAYS 天内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) + LocalDateTime minQuitTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); + members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); + if (CollUtil.isEmpty(members)) { + return Collections.emptyList(); + } + + // 2. 批量查询群信息(不按 status / banned 过滤,已解散 / 封禁的群也要返回,供前端展示历史消息的群名 / 头像) + Set groupIds = convertSet(members, ImGroupMemberDO::getGroupId); + return groupMapper.selectByIds(groupIds); + } + + private ImGroupServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + /** + * 根据用户编号集合,拼接用户昵称字符串(逗号分隔) + * + * @param userIds 用户编号集合 + * @return 昵称字符串,如 "张三,李四" + */ + private String getUserNicknames(Collection userIds) { + Map userMap = adminUserApi.getUserMap(userIds); + return userIds.stream() + .map(id -> userMap.containsKey(id) ? userMap.get(id).getNickname() : String.valueOf(id)) + .collect(Collectors.joining(",")); + } + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java new file mode 100644 index 0000000..bfd279b --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * IM 群聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImGroupMessageService { + + /** + * 发送群聊消息 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); + + /** + * 拉取群聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullGroupMessageList(Long userId, Long minId, Integer size); + + /** + * 标记群聊消息已读 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 已读到的消息编号 + */ + void readGroupMessages(Long userId, Long groupId, Long messageId); + + /** + * 撤回群聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的提示消息 + */ + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); + + /** + * 获取群消息的已读用户列表 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param messageId 消息编号 + * @return 已读用户编号列表 + */ + List getGroupReadUsers(Long userId, Long groupId, Long messageId); + + /** + * 查询群聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param reqVO 拉取请求 + * @return 消息列表(按 id 倒序) + */ + List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); + + /** + * 清理用户在某群的已读位置缓存 + *

+ * 用于成员退群场景 + * + * @param groupId 群编号 + * @param userId 用户编号 + */ + void deleteReadMaxMessageId(Long groupId, Long userId); + + /** + * 批量清理用户在某群的已读位置缓存 + *

+ * 用于批量踢出场景 + * + * @param groupId 群编号 + * @param userIds 用户编号集合 + */ + void deleteReadMaxMessageIds(Long groupId, Collection userIds); + + /** + * 清理某群所有用户的已读位置缓存 + *

+ * 用于群解散场景 + * + * @param groupId 群编号 + */ + void deleteReadMaxMessageIdMap(Long groupId); + + /** + * 发送群聊系统提示消息(TIP_TEXT) + *

+ * 插入一条 TIP_TEXT 消息到群聊记录,并推送给指定用户 + * + * @param senderId 发送人编号(操作者) + * @param groupId 群编号 + * @param memberUserIds 推送目标用户编号集合 + * @param content 提示内容 + */ + void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content); + +} + diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java new file mode 100644 index 0000000..46b6d46 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -0,0 +1,549 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; + +/** + * IM 群聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImGroupMessageServiceImpl implements ImGroupMessageService { + + /** + * 仅用于规避群消息 pull 的"假空页": + * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。 + * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。 + */ + private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; + + @Resource + private ImGroupMessageMapper groupMessageMapper; + @Resource + private GroupMessageReadRedisDAO groupMessageReadRedisDAO; + + @Resource + private ImGroupService groupService; + @Resource + private ImGroupMemberService groupMemberService; + @Resource + private ImSensitiveWordService sensitiveWordService; + + @Resource + private ImWebSocketService imWebSocketService; + + @Override + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO) { + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 + ImGroupMessageDO existing = groupMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + log.info("[sendGroupMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", + senderId, reqVO.getClientMessageId(), existing.getId()); + return existing; + } + // 1.2 校验群存在、发送人仍在群中 + groupService.validateGroupExists(reqVO.getGroupId()); + groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId); + // 1.3 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + sensitiveWordService.validateText(reqVO.getContent()); + } + + // 2. 构建并保存消息 + ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt()) + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); + groupMessageMapper.insert(message); + + // 3. WebSocket 推送给群成员(异步) + sendGroupMessageEvent(message); + return message; + } + + @Override + public List pullGroupMessageList(Long userId, Long minId, Integer size) { + if (size > MESSAGE_MAX_PULL_SIZE) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); + } + + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); + + // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试 + List activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); + Map memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); + List messages = new ArrayList<>(); + if (CollUtil.isNotEmpty(activeMembers)) { + List groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); + messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap)); + } + // 1.2 补齐"退群前"的消息: + // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进; + // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。 + messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap)); + + // 2. 合并后统一过滤,得到当前用户可见的结果 + List result = filterGroupMessageList(messages, memberMap, userId, size); + + // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 + appendMessageStatusAndReceipt(userId, result); + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); + return result; + } + + /** + * 拉取当前仍在群的主路径消息。 + * + * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测, + * 直到命中可见消息或确认该来源已耗尽。 + */ + private List pullActiveGroupMessageList(Long userId, List groupIds, Long minId, + Integer size, LocalDateTime minSendTime, + Map memberMap) { + // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界 + Long activeMinId = minId; + for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { + // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束 + List messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId, + minSendTime, size); + if (CollUtil.isEmpty(messages)) { + return Collections.emptyList(); + } + boolean hasVisibleMessage = CollUtil.anyMatch(messages, + message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId)); + boolean sourceExhausted = messages.size() < size; + // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息 + if (hasVisibleMessage || sourceExhausted) { + return messages; + } + + // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止 + Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); + if (maxMessageId == null || maxMessageId <= activeMinId) { + return Collections.emptyList(); + } + activeMinId = maxMessageId; + } + return Collections.emptyList(); + } + + /** + * 拉取已离开群路径的消息(退群前消息) + */ + private List pullQuitGroupMessageList(Long userId, Long minId, Integer size, + LocalDateTime minSendTime, + Map memberMap) { + // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤 + LocalDateTime minQuitTime = minSendTime; + if (minId != null && minId > 0) { + ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); + if (minMessage != null && minMessage.getSendTime() != null + && minMessage.getSendTime().isAfter(minSendTime)) { + minQuitTime = minMessage.getSendTime(); + } + } + // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限 + List messages = new ArrayList<>(); + List quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); + for (ImGroupMemberDO quitMember : quitMembers) { + // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用 + List quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); + if (CollUtil.isEmpty(quitGroupMessages)) { + continue; + } + messages.addAll(quitGroupMessages); + memberMap.put(quitMember.getGroupId(), quitMember); + } + return messages; + } + + /** + * 过滤一批原始群消息,得到当前用户可见的返回结果 + */ + private List filterGroupMessageList(List messages, + Map memberMap, + Long userId, Integer size) { + // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, + // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 + return messages.stream() + .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) + .sorted(Comparator.comparing(ImGroupMessageDO::getId)) + .limit(size) + .toList(); + } + + /** + * 补全消息已读态和回执已读人数 + * + * 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD + * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 + */ + @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) + private void appendMessageStatusAndReceipt(Long userId, List messages) { + if (CollUtil.isEmpty(messages)) { + return; + } + Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 + Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) + Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 + for (ImGroupMessageDO message : messages) { + // 消息已读态(status):撤回 > 已读 > 未读 + Long groupId = message.getGroupId(); + long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { + Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId); + return readMaxMsgId != null ? readMaxMsgId : -1L; + }); + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + // 保持撤回态 + } else if (readMaxMessageId >= message.getId()) { + message.setStatus(ImMessageStatusEnum.READ.getStatus()); + } else { + message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); + } + + // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) + if (ObjUtil.notEqual(message.getSenderId(), userId) + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { + continue; + } + Map positions = readPositionsByGroup.computeIfAbsent(groupId, + gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid)); + List allMembers = membersByGroup.computeIfAbsent(groupId, + groupMemberService::getGroupMemberListByGroupId); + Set visibleUserIds = getVisibleUserIds(message, allMembers); + visibleUserIds.remove(message.getSenderId()); + int readCount = getSumValue(visibleUserIds, + uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, + Integer::sum, 0); + message.setReadCount(readCount); + } + } + + @Override + public void readGroupMessages(Long userId, Long groupId, Long messageId) { + Assert.notNull(messageId, "已读消息编号不能为空"); + // 1. 校验用户在群中(权限校验) + groupMemberService.validateMemberInGroup(groupId, userId); + + // 2. 已读位置未前进,直接返回 + Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId); + if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { + return; + } + // 3. 更新 Redis 群已读位置 + groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId); + + // 4. 异步发送 READ 事件 + 刷新范围内的群回执 + getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImGroupMessageDO message = validateGroupMessageExists(messageId); + // 1.2 只能撤回自己发送的消息 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.4 只允许撤回限定时间内的消息 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); + } + // 1.5 校验撤回人仍在群中 + groupMemberService.validateMemberInGroup(message.getGroupId(), userId); + + // 2. 更新原消息状态为撤回 + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 插入一条 TIP_TEXT 消息作为撤回提示 + RecallMessage recallContent = new RecallMessage().setMessageId(messageId); + ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(userId).setGroupId(message.getGroupId()) + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(JsonUtils.toJsonString(recallContent)) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + groupMessageMapper.insert(recallMessage); + + // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示) + sendGroupMessageEvent(recallMessage); + return recallMessage; + } + + // TODO @芋艿:这个方法在优化下; + @Override + public List getGroupReadUsers(Long userId, Long groupId, Long messageId) { + // 1.1 校验用户在群中(权限校验) + groupMemberService.validateMemberInGroup(groupId, userId); + // 1.2 获取消息 + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); + if (message == null) { + return Collections.emptyList(); + } + + // 2. 获取所有成员和已读位置 + List allMembers = groupMemberService.getGroupMemberListByGroupId(groupId); + Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + + // 3. 计算该消息的可见成员集合(排除发送者自己) + Set visibleUserIds = getVisibleUserIds(message, allMembers); + visibleUserIds.remove(message.getSenderId()); + + // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 + // DONE @AI:根据返回结果,进行简化;应该都是 Long、Long;——RedisDAO 已返回 Map,直接使用 + List readUserIds = new ArrayList<>(); + allPositions.forEach((uid, readMaxMessageId) -> { + if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) { + readUserIds.add(uid); + } + }); + return readUserIds; + } + + @Override + public List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { + // 1. 校验用户在群中 + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); + + // 2. 查询历史消息(仅入群之后) + return groupMessageMapper.selectHistoryList( + reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); + } + + // ========== 异步 WebSocket 推送 ========== + + /** + * 发送群聊消息 WebSocket 事件 + *

+ * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: + *

    + *
  • 所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;
  • + *
  • ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;
  • + *
+ * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 + * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, + * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 + *

+ * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 + */ + public void sendGroupMessageEvent(ImGroupMessageDO message) { + ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); + // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); + Set targetUserIds = getVisibleUserIds(message, memberUserIds); + imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); + } + + /** + * 发送已读 + 刷新群回执 WebSocket 事件 + * + * @param userId 当前用户编号 + * @param groupId 群编号 + * @param prevMaxMessageId 上次已读位置 + * @param newMaxMessageId 本次已读位置 + */ + @Async + @SuppressWarnings("DataFlowIssue") + public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) { + // 1. 发送 READ 事件给自己的其他终端(多端同步) + imWebSocketService.sendGroupMessageAsync(userId, + ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId)); + + // 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息 + List pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt( + groupId, prevMaxMessageId, newMaxMessageId); + if (CollUtil.isEmpty(pendingMessages)) { + return; + } + List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); + Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); + for (ImGroupMessageDO message : pendingMessages) { + // 2.1.1 统计可见成员中的已读人数 + Set visibleUserIds = getVisibleUserIds(message, activeMembers); + visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读 + if (CollUtil.isEmpty(visibleUserIds)) { + continue; + } + int readCount = getSumValue(visibleUserIds, + uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, + Integer::sum, 0); + // 2.1.2 全部已读 → 标记回执完成 + Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); + if (readCount >= visibleUserIds.size()) { + newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); + groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) + .setReceiptStatus(newReceiptStatus)); + } + + // 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度) + imWebSocketService.sendGroupMessageAsync(message.getSenderId(), + ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus)); + } + } + + // ========== 私有工具方法 ========== + + /** + * 校验群聊消息存在 + * + * @param messageId 消息编号 + * @return 群聊消息 + */ + private ImGroupMessageDO validateGroupMessageExists(Long messageId) { + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + return message; + } + + /** + * 判断一条群消息对某个群成员是否可见 + * + * @param msg 消息 + * @param member 群成员 + * @param userId 当前用户编号(用于定向消息过滤) + * @return 是否可见 + */ + private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) { + if (member == null) { + return false; + } + // 1. 入群时间晚于消息发送时间 → 不可见 + if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) { + return false; + } + // 2. 已退群且退群时间早于消息发送时间 → 不可见 + if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) + && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) { + return false; + } + // 3.1 无定向接收列表 → 全员可见 + if (CollUtil.isEmpty(msg.getReceiverUserIds())) { + return true; + } + // 3.2 当前用户在定向列表中,或本人即发送者 → 可见 + return msg.getReceiverUserIds().contains(userId) + || ObjUtil.equal(msg.getSenderId(), userId); + } + + /** + * 计算一条群消息的可见成员集合(含发送者) + */ + private Set getVisibleUserIds(ImGroupMessageDO message, List members) { + return convertSet(members, ImGroupMemberDO::getUserId, + member -> isMessageVisible(message, member, member.getUserId())); + } + + /** + * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 + *

+ * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, + * 只应用 {@code receiverUserIds} 定向过滤;语义与 + * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 + */ + private Set getVisibleUserIds(ImGroupMessageDO message, Collection memberUserIds) { + if (CollUtil.isEmpty(memberUserIds)) { + return new HashSet<>(); + } + // 无定向接收列表 → 全员可见 + if (CollUtil.isEmpty(message.getReceiverUserIds())) { + return new HashSet<>(memberUserIds); + } + // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) + Set allowed = new HashSet<>(message.getReceiverUserIds()); + if (message.getSenderId() != null) { + allowed.add(message.getSenderId()); + } + Set result = new HashSet<>(); + for (Long userId : memberUserIds) { + if (allowed.contains(userId)) { + result.add(userId); + } + } + return result; + } + + @Override + public void deleteReadMaxMessageId(Long groupId, Long userId) { + groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId); + } + + @Override + public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { + groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds); + } + + @Override + public void deleteReadMaxMessageIdMap(Long groupId) { + groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); + } + + @Override + public void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content) { + // 1. 插入 TIP_TEXT 消息 + ImGroupMessageDO tipMessage = new ImGroupMessageDO() + .setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(senderId).setGroupId(groupId) + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + groupMessageMapper.insert(tipMessage); + + // 2. 推送给指定用户 + imWebSocketService.sendGroupMessageAsync(memberUserIds, + ImGroupMessageDTO.ofSend(tipMessage)); + } + + private ImGroupMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} \ No newline at end of file diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java new file mode 100644 index 0000000..0f21bd1 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; + +import java.util.List; + +/** + * IM 私聊消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImPrivateMessageService { + + /** + * 发送私聊消息 + * + * @param senderId 发送人编号 + * @param reqVO 发送请求 + * @return 消息 + */ + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); + + /** + * 拉取私聊消息(增量) + * + * @param userId 当前用户编号 + * @param minId 最小消息 id(不含) + * @param size 拉取数量 + * @return 消息列表 + */ + List pullPrivateMessageList(Long userId, Long minId, Integer size); + + /** + * 标记私聊消息已读 + * + * @param userId 当前用户编号 + * @param receiverId 接收方用户编号(对方) + */ + void readPrivateMessages(Long userId, Long receiverId); + + /** + * 撤回私聊消息 + * + * @param userId 当前用户编号 + * @param messageId 消息编号 + * @return 撤回后的消息 + */ + ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); + + /** + * 查询私聊历史消息(游标拉取) + * + * @param userId 当前用户编号 + * @param reqVO 拉取请求 + * @return 消息列表(按 id 倒序) + */ + List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); + + /** + * 发送系统提示消息(TIP_TEXT) + *

+ * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。 + * + * @param senderId 发送人编号 + * @param receiverId 接收人编号 + * @param content 提示文本 + */ + void sendTipPrivateMessage(Long senderId, Long receiverId, String content); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java new file mode 100644 index 0000000..b785ee9 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -0,0 +1,182 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; +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.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; + +/** + * IM 私聊消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { + + @Resource + private ImPrivateMessageMapper privateMessageMapper; + + @Resource + private ImFriendService friendService; + @Resource + private ImSensitiveWordService sensitiveWordService; + + @Resource + private ImWebSocketService imWebSocketService; + + @Override + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) { + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 + ImPrivateMessageDO existing = privateMessageMapper.selectBySenderIdAndClientMessageId( + senderId, reqVO.getClientMessageId()); + if (existing != null) { + log.info("[sendPrivateMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", + senderId, reqVO.getClientMessageId(), existing.getId()); + return existing; + } + // 1.2 好友校验 + if (!friendService.isFriend(senderId, reqVO.getReceiverId())) { + throw exception(FRIEND_NOT_FRIEND); + } + // 1.3 文本消息敏感词过滤 + if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { + sensitiveWordService.validateText(reqVO.getContent()); + } + + // 2. 构建并保存消息 + ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); + privateMessageMapper.insert(message); + + // 3. WebSocket 异步推送(接收方 + 发送方多端同步) + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); + imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); + imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage); + return message; + } + + @Override + public List pullPrivateMessageList(Long userId, Long minId, Integer size) { + if (size > MESSAGE_MAX_PULL_SIZE) { + throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); + } + // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 + LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_PRIVATE_PULL_MAX_DAYS); + + // 根据 minId 和 minSendTime 拉取消息,避免 minId 恰好被发出后才拉取,导致漏消息 + List messages = privateMessageMapper.selectListByMinId(userId, minId, minSendTime, size); + log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]", + userId, minId, size, messages.size()); + return messages; + } + + // TODO @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 + @Override + public void readPrivateMessages(Long userId, Long receiverId) { + // 1.1 查询未读消息列表 + List unreadMessages = privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus( + receiverId, userId, ImMessageStatusEnum.UNREAD.getStatus()); + if (CollUtil.isEmpty(unreadMessages)) { + return; + } + // 1.2 根据 id in + status 条件更新,避免并发问题 + List messageIds = convertList(unreadMessages, ImPrivateMessageDO::getId); + privateMessageMapper.updateByIdsAndStatus(messageIds, ImMessageStatusEnum.UNREAD.getStatus(), + new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); + + // 2. 异步发送 READ + RECEIPT 事件(携带已读位置) + Long maxReadId = CollUtil.max(messageIds); + imWebSocketService.sendPrivateMessageAsync(userId, + ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId)); + imWebSocketService.sendPrivateMessageAsync(receiverId, + ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { + // 1.1 校验消息存在 + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); + if (message == null) { + throw exception(MESSAGE_NOT_EXISTS); + } + // 1.2 只能撤回自己发送的消息 + if (ObjUtil.notEqual(message.getSenderId(), userId)) { + throw exception(MESSAGE_RECALL_DENIED); + } + // 1.3 不能重复撤回 + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { + throw exception(MESSAGE_ALREADY_RECALLED); + } + // 1.4 只允许撤回限定时间内的消息 + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); + } + + // 2. 更新原消息状态为撤回 + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); + + // 3. 插入一条 TIP_TEXT 消息作为撤回提示 + RecallMessage recallContent = new RecallMessage().setMessageId(messageId); + ImPrivateMessageDO tipMessage = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) + .setSenderId(userId).setReceiverId(message.getReceiverId()) + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(JsonUtils.toJsonString(recallContent)) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); + privateMessageMapper.insert(tipMessage); + + // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示) + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(tipMessage); + imWebSocketService.sendPrivateMessageAsync(tipMessage.getReceiverId(), websocketMessage); + imWebSocketService.sendPrivateMessageAsync(tipMessage.getSenderId(), websocketMessage); + return tipMessage; + } + + @Override + public List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { + return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); + } + + @Override + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { + // 1. 插入 TIP_TEXT 消息 + ImPrivateMessageDO tipMessage = new ImPrivateMessageDO() + .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId) + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); + privateMessageMapper.insert(tipMessage); + + // 2. 推送给双方 + ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage); + imWebSocketService.sendPrivateMessageAsync(receiverId, dto); + imWebSocketService.sendPrivateMessageAsync(senderId, dto); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java new file mode 100644 index 0000000..25bd621 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +/** + * IM 敏感词 Service 接口 + * + * @author 芋道源码 + */ +public interface ImSensitiveWordService { + + /** + * 校验文本是否包含敏感词 + * + * @param text 待校验文本 + */ + void validateText(String text); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java new file mode 100644 index 0000000..cacb7a6 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; + +// DONE @AI:已实现内存化,启动时加载 + 定时刷新 +/** + * IM 敏感词 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { + + @Resource + private ImSensitiveWordMapper imSensitiveWordMapper; + + /** + * 内存缓存的敏感词列表 + */ + private volatile List sensitiveWords = Collections.emptyList(); + + /** + * 初始化:启动时加载敏感词到内存 + */ + @PostConstruct + public void init() { + loadSensitiveWords(); + } + + /** + * 定时刷新:每 5 分钟重新加载一次 + */ + @Scheduled(fixedDelay = 5 * 60 * 1000, initialDelay = 5 * 60 * 1000) + public void refresh() { + loadSensitiveWords(); + } + + private void loadSensitiveWords() { + List enabledWords = imSensitiveWordMapper.selectListByStatus( + CommonStatusEnum.ENABLE.getStatus()); + sensitiveWords = enabledWords.stream() + .map(ImSensitiveWordDO::getWord) + .collect(Collectors.toList()); + log.info("[loadSensitiveWords][加载 IM 敏感词 {} 个]", sensitiveWords.size()); + } + + @Override + public void validateText(String text) { + if (text == null || text.isEmpty()) { + return; + } + // 内存匹配 + for (String word : sensitiveWords) { + if (text.contains(word)) { + throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED); + } + } + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java new file mode 100644 index 0000000..f7457fe --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; + +import java.util.Collection; +import java.util.Collections; + +/** + * IM WebSocket 推送 Service 接口 + *

+ * 统一封装 WebSocket 消息推送,所有方法默认异步执行。 + * + * @author 芋道源码 + */ +public interface ImWebSocketService { + + /** + * 异步推送私聊消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 私聊消息 DTO + */ + void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto); + + /** + * 异步推送群聊消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 群聊消息 DTO + */ + default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) { + sendGroupMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送群聊消息给多个用户 + * + * @param userIds 目标用户编号列表 + * @param dto 群聊消息 DTO + */ + void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java new file mode 100644 index 0000000..ab8da84 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; + +// TODO @芋艿:后续要分析下,是不是要拆分下 notify(类似好友、群的变更) +/** + * IM WebSocket 推送 Service 实现类 + *

+ * 当调用方处于事务中时,推送会延迟到事务提交后再异步执行, + * 避免客户端收到 WebSocket 消息时数据库变更尚未可见。 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class ImWebSocketServiceImpl implements ImWebSocketService { + + @Resource + private WebSocketMessageSender webSocketMessageSender; + + @Override + public void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) { + // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交 + // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理) + executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userId, dto)); + } + + @Override + public void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto)); + } + + /** + * 异步发送私聊 WebSocket 消息 + */ + @Async + public void doSendPrivateMessage(Long userId, ImPrivateMessageDTO dto) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImPrivateMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + + /** + * 异步发送群聊 WebSocket 消息 + */ + @Async + public void doSendGroupMessage(Collection userIds, ImGroupMessageDTO dto) { + for (Long userId : userIds) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImGroupMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendGroupMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + } + + /** + * 事务感知的任务调度: + * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 + * - 无事务:直接执行(如非 @Transactional 方法中的调用) + * + * @param task 待执行的推送任务(内部通过 getSelf() 走 @Async 异步执行) + */ + private void executeAfterTransaction(Runnable task) { + // 情况一:没有事务,直接执行 + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + // 情况二:有事务,注册 afterCommit 事件,在事务提交后执行 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + task.run(); + } + + }); + } + + /** + * 获得自身的代理对象,解决 @Async AOP 代理问题 + */ + private ImWebSocketServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java new file mode 100644 index 0000000..9d06922 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 群聊消息 WebSocket 统一推送 DTO + *

+ * 发送、已读、回执都通过 {@link #type} 字段区分, + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 + * + * @author 芋道源码 + */ +@Data +public class ImGroupMessageDTO { + + public static final String TYPE = "im-group-message"; + + /** + * 消息编号 + *

+ * 普通消息:当前消息 id + * READ:无(群已读不需要) + * RECEIPT:需要回执的目标消息 id + */ + private Long id; + /** + * 客户端消息编号 + */ + private String clientMessageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 群编号 + */ + private Long groupId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 消息状态 + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + /** + * @ 目标用户编号列表 + */ + private List atUserIds; + /** + * 定向接收用户编号列表 + */ + private List receiverUserIds; + /** + * 群回执状态 + */ + private Integer receiptStatus; + /** + * 群回执已读人数 + */ + private Integer readCount; + + // ========== 静态工厂方法 ========== + + /** + * 构建发送消息 DTO + * + * @param message 群聊消息 DO + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) { + return BeanUtils.toBean(message, ImGroupMessageDTO.class); + } + + /** + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个群") + * + * @param senderId 当前用户编号 + * @param groupId 群编号 + * @param readId 已读位置(最大已读消息编号) + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) { + return new ImGroupMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setSenderId(senderId).setGroupId(groupId); + } + + /** + * 构建群回执 DTO(广播回执状态和已读人数) + * + * @param messageId 消息编号 + * @param groupId 群编号 + * @param readCount 已读人数 + * @param receiptStatus 回执状态 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId, + Integer readCount, Integer receiptStatus) { + return new ImGroupMessageDTO() + .setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType()) + .setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus); + } + + // ==================== 群变更相关 ==================== + + /** + * 构建群创建推送 DTO(通知群成员"有一个新群") + * + * @param senderId 创建者(群主)用户编号 + * @param groupId 群编号 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofGroupCreate(Long senderId, Long groupId) { + return new ImGroupMessageDTO() + .setType(ImMessageTypeEnum.GROUP_CREATE.getType()) + .setSenderId(senderId).setGroupId(groupId) + .setSendTime(LocalDateTime.now()); + } + + /** + * 构建群信息变更推送 DTO(通知所有群成员"群信息变了") + * + * @param senderId 操作者用户编号 + * @param groupId 群编号 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofGroupUpdate(Long senderId, Long groupId) { + return new ImGroupMessageDTO() + .setType(ImMessageTypeEnum.GROUP_UPDATE.getType()) + .setSenderId(senderId).setGroupId(groupId) + .setSendTime(LocalDateTime.now()); + } + + /** + * 构建群删除推送 DTO(通知群成员"群已移除") + *

+ * 适用于群解散、退群、踢出等场景 + * + * @param senderId 操作者用户编号 + * @param groupId 群编号 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofGroupDelete(Long senderId, Long groupId) { + return new ImGroupMessageDTO() + .setType(ImMessageTypeEnum.GROUP_DELETE.getType()) + .setSenderId(senderId).setGroupId(groupId) + .setSendTime(LocalDateTime.now()); + } + + /** + * 构建群成员信息变更推送 DTO(多端同步:通知自己"在某群的个人设置变了") + * + * @param senderId 当前用户编号 + * @param groupId 群编号 + * @return 群聊 DTO + */ + public static ImGroupMessageDTO ofGroupMemberUpdate(Long senderId, Long groupId) { + return new ImGroupMessageDTO() + .setType(ImMessageTypeEnum.GROUP_MEMBER_UPDATE.getType()) + .setSenderId(senderId).setGroupId(groupId) + .setSendTime(LocalDateTime.now()); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java new file mode 100644 index 0000000..a609561 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java @@ -0,0 +1,147 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * IM 私聊消息 WebSocket 统一推送 DTO + *

+ * 发送、已读、回执、撤回都通过 {@link #type} 字段区分, + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class ImPrivateMessageDTO { + + public static final String TYPE = "im-private-message"; + + /** + * 消息编号 + *

+ * 普通消息:当前消息 id + * READ:已读位置(我已读到这条消息) + * RECEIPT:已读位置(对方已读到这条消息) + * RECALL:被撤回的原消息 id + */ + private Long id; + /** + * 客户端消息编号 + */ + private String clientMessageId; + /** + * 发送人编号 + */ + private Long senderId; + /** + * 接收人编号 + */ + private Long receiverId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容 + */ + private String content; + /** + * 消息状态 + */ + private Integer status; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + // ========== 静态工厂方法 ========== + + /** + * 构建发送消息 DTO + * + * @param message 私聊消息 DO + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) { + return BeanUtils.toBean(message, ImPrivateMessageDTO.class); + } + + /** + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话") + * + * @param senderId 当前用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + + /** + * 构建已读回执 DTO(通知对方"我已读了你的消息") + * + * @param senderId 已读方的用户编号 + * @param receiverId 对方用户编号 + * @param readId 已读位置(最大已读消息编号) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) { + return new ImPrivateMessageDTO() + .setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType()) + .setSenderId(senderId).setReceiverId(receiverId); + } + + // ==================== 好友变更相关 ==================== + + /** + * 构建好友添加推送 DTO(通知对方"你有一位新好友") + * + * @param userId 当前用户编号(即发起添加的用户) + * @param friendUserId 好友的用户编号(即接收通知的用户) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofFriendAdd(Long userId, Long friendUserId) { + return new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.FRIEND_ADD.getType()) + .setSenderId(userId).setReceiverId(friendUserId) + .setSendTime(LocalDateTime.now()); + } + + /** + * 构建好友删除推送 DTO(通知对方"好友关系已解除") + * + * @param userId 当前用户编号(即发起删除的用户) + * @param friendUserId 好友的用户编号(即接收通知的用户) + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofFriendDelete(Long userId, Long friendUserId) { + return new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.FRIEND_DELETE.getType()) + .setSenderId(userId).setReceiverId(friendUserId) + .setSendTime(LocalDateTime.now()); + } + + /** + * 构建好友更新推送 DTO(通知自己多端同步好友属性变更) + * + * @param userId 当前用户编号 + * @param friendUserId 好友的用户编号 + * @return 私聊 DTO + */ + public static ImPrivateMessageDTO ofFriendUpdate(Long userId, Long friendUserId) { + return new ImPrivateMessageDTO() + .setType(ImMessageTypeEnum.FRIEND_UPDATE.getType()) + .setSenderId(userId).setReceiverId(friendUserId) + .setSendTime(LocalDateTime.now()); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java new file mode 100644 index 0000000..884e1b0 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 语音消息内容 + */ +@Data +public class AudioMessage { + + /** + * 语音文件 URL + */ + private String url; + /** + * 语音时长(秒) + */ + private Integer duration; + /** + * 文件大小(字节) + */ + private Long size; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java new file mode 100644 index 0000000..e567b27 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 名片消息内容 + */ +@Data +public class CardMessage { + + /** + * 用户编号 + */ + private Long userId; + /** + * 用户昵称 + */ + private String nickname; + /** + * 用户头像 + */ + private String avatar; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java new file mode 100644 index 0000000..7965179 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 文件消息内容 + */ +@Data +public class FileMessage { + + /** + * 文件 URL + */ + private String url; + /** + * 文件名 + */ + private String name; + /** + * 文件大小(字节) + */ + private Long size; + /** + * 文件类型(扩展名) + */ + private String type; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java new file mode 100644 index 0000000..c3d272d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 图片消息内容 + */ +@Data +public class ImageMessage { + + /** + * 图片 URL + */ + private String url; + /** + * 缩略图 URL + */ + private String thumbnailUrl; + /** + * 图片宽度 + */ + private Integer width; + /** + * 图片高度 + */ + private Integer height; + /** + * 图片大小(字节) + */ + private Long size; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java new file mode 100644 index 0000000..8c36d1a --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 位置消息内容 + */ +@Data +public class LocationMessage { + + /** + * 位置描述 + */ + private String description; + /** + * 经度 + */ + private Double longitude; + /** + * 纬度 + */ + private Double latitude; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java new file mode 100644 index 0000000..5df78f2 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 撤回提示消息内容 + */ +@Data +@Accessors(chain = true) +public class RecallMessage { + + /** + * 被撤回的原消息编号 + */ + private Long messageId; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java new file mode 100644 index 0000000..747fabd --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 文本消息内容 + */ +@Data +public class TextMessage { + + /** + * 文本内容 + */ + private String content; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java new file mode 100644 index 0000000..3aac314 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; + +/** + * 视频消息内容 + */ +@Data +public class VideoMessage { + + /** + * 视频文件 URL + */ + private String url; + /** + * 视频封面 URL + */ + private String coverUrl; + /** + * 视频时长(秒) + */ + private Integer duration; + /** + * 视频宽度 + */ + private Integer width; + /** + * 视频高度 + */ + private Integer height; + /** + * 文件大小(字节) + */ + private Long size; + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java new file mode 100644 index 0000000..2ce7dec --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java @@ -0,0 +1,127 @@ +package cn.iocoder.yudao.module.im.dal.mysql.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 群成员 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMemberMapperTest extends BaseDbUnitTest { + + @Resource + private ImGroupMemberMapper mapper; + + // ========== selectQuitListByUserId ========== + + @Test + public void testSelectQuitListByUserId_onlyDisabled() { + // 准备:一条 ENABLE + 一条 DISABLE + mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); + ImGroupMemberDO quit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); + quit.setQuitTime(LocalDateTime.now().minusDays(1)); + mapper.insert(quit); + + // 调用:minQuitTime 为 null,不按时间过滤 + List result = mapper.selectQuitListByUserId(1L, null); + + // 断言:只返回退群记录 + assertEquals(1, result.size()); + assertEquals(quit.getId(), result.get(0).getId()); + } + + @Test + public void testSelectQuitListByUserId_filterByMinQuitTime() { + // 准备:两条退群记录,时间一早一晚 + LocalDateTime old = LocalDateTime.now().minusDays(10); + LocalDateTime recent = LocalDateTime.now().minusDays(1); + ImGroupMemberDO oldQuit = buildMember(10L, 1L, CommonStatusEnum.DISABLE); + oldQuit.setQuitTime(old); + mapper.insert(oldQuit); + ImGroupMemberDO recentQuit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); + recentQuit.setQuitTime(recent); + mapper.insert(recentQuit); + + // 调用:minQuitTime = now - 5 天,只返回近期退群 + List result = mapper.selectQuitListByUserId(1L, LocalDateTime.now().minusDays(5)); + assertEquals(1, result.size()); + assertEquals(recentQuit.getId(), result.get(0).getId()); + + // 调用:minQuitTime 为 null,不过滤 + List all = mapper.selectQuitListByUserId(1L, null); + assertEquals(2, all.size()); + } + + // ========== updateByGroupIdAndUserIdsAndStatus ========== + + @Test + public void testUpdateByGroupIdAndUserIdsAndStatus_onlyUpdatesOldStatusMatches() { + // 准备:u1 ENABLE,u2 DISABLE(已退群,不应更新) + ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(u1); + ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.DISABLE); + u2.setQuitTime(LocalDateTime.now().minusDays(5)); + mapper.insert(u2); + + // 调用:把 ENABLE 状态的成员移除 + LocalDateTime now = LocalDateTime.now(); + mapper.updateByGroupIdAndUserIdsAndStatus(10L, List.of(1L, 2L), + CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()).setQuitTime(now)); + + // 断言:u1 被更新 + ImGroupMemberDO after1 = mapper.selectById(u1.getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), after1.getStatus()); + assertNotNull(after1.getQuitTime()); + // 断言:u2 的 quitTime 不变(仍为 5 天前) + ImGroupMemberDO after2 = mapper.selectById(u2.getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), after2.getStatus()); + assertTrue(after2.getQuitTime().isBefore(now.minusDays(1))); + } + + // ========== updateByGroupIdAndStatus ========== + + @Test + public void testUpdateByGroupIdAndStatus_allActiveInGroup() { + // 准备:群 10 有 2 个活跃 + 1 个已退;群 20 有 1 个活跃 + ImGroupMemberDO a1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(a1); + ImGroupMemberDO a2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); + mapper.insert(a2); + ImGroupMemberDO a3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); + a3.setQuitTime(LocalDateTime.now().minusDays(5)); + mapper.insert(a3); + ImGroupMemberDO b1 = buildMember(20L, 1L, CommonStatusEnum.ENABLE); + mapper.insert(b1); + + // 调用:解散群 10 + mapper.updateByGroupIdAndStatus(10L, CommonStatusEnum.ENABLE.getStatus(), + new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setQuitTime(LocalDateTime.now())); + + // 断言:群 10 的活跃成员全部变为 DISABLE,群 20 的不受影响 + assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a1.getId()).getStatus()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a2.getId()).getStatus()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), mapper.selectById(b1.getId()).getStatus()); + } + + // ========== 工具方法 ========== + + private ImGroupMemberDO buildMember(Long groupId, Long userId, CommonStatusEnum status) { + return ImGroupMemberDO.builder() + .groupId(groupId).userId(userId) + .status(status.getStatus()) + .joinTime(LocalDateTime.now().minusDays(30)) + .build(); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java new file mode 100644 index 0000000..c14b761 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -0,0 +1,287 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 群聊消息 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMessageMapperTest extends BaseDbUnitTest { + + @Resource + private ImGroupMessageMapper mapper; + + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + + // ========== selectListByMinId ========== + + @Test + public void testSelectListByMinId_onlyReturnsGroupsInIdList() { + // 准备:群 10 的消息 + 群 20 的消息 + ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg10); + ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg20); + + // 调用:只查群 10 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + // 断言:只有 msg10 + assertEquals(1, result.size()); + assertEquals(msg10.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_excludeRecall() { + // 准备:一条正常 + 一条撤回 + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); + + assertEquals(1, result.size()); + assertEquals(normal.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_sendTimeWindow() { + // 准备:一条在窗口内,一条在窗口外 + ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + inWindow.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(inWindow); + ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + outWindow.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(outWindow); + + List result = mapper.selectListByMinId(List.of(10L), 0L, + LocalDateTime.now().minusDays(30), 100); + + assertEquals(1, result.size()); + assertEquals(inWindow.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_sortAscLimit() { + // 准备:插入 3 条 + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + // 调用:size=2,返回 id 最小的 2 条 + List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 2); + + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() < result.get(1).getId()); + assertEquals(m1.getId(), result.get(0).getId()); + assertEquals(m2.getId(), result.get(1).getId()); + } + + // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ========== + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() { + // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后" + LocalDateTime quitTime = LocalDateTime.now().minusDays(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusDays(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusHours(1)); + mapper.insert(after); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + // 断言:只返回退群前消息 + assertEquals(1, result.size()); + assertEquals(before.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() { + // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条) + LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0); + ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + boundary.setSendTime(quitTime); + mapper.insert(boundary); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + } + + @Test + public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() { + // 准备:群 10 和群 20 各一条消息 + LocalDateTime quitTime = LocalDateTime.now(); + ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + in10.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in10); + ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + in20.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(in20); + + List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( + 10L, 0L, FAR_PAST, quitTime, 100); + + assertEquals(1, result.size()); + assertEquals(in10.getId(), result.get(0).getId()); + } + + // ========== selectHistoryList ========== + + @Test + public void testSelectHistoryList_basic() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + // 别的群 + ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(other); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + // 断言:只返回 10 群的,按 id 倒序 + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() > result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_excludeRecall() { + ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(normal); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + mapper.insert(recalled); + + List result = mapper.selectHistoryList(10L, null, 100, null); + + assertEquals(1, result.size()); + assertEquals(normal.getId(), result.get(0).getId()); + } + + @Test + public void testSelectHistoryList_maxIdCursor() { + ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m1); + ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m2); + ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(m3); + + List result = mapper.selectHistoryList(10L, m3.getId(), 100, null); + + // 断言:只返回 id < m3.id 的,即 m1、m2 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); + } + + @Test + public void testSelectHistoryList_filterByJoinTime() { + // 准备:一条在入群前,一条在入群后 + LocalDateTime joinTime = LocalDateTime.now().minusHours(1); + ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + before.setSendTime(LocalDateTime.now().minusHours(2)); + mapper.insert(before); + ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + after.setSendTime(LocalDateTime.now().minusMinutes(30)); + mapper.insert(after); + + List result = mapper.selectHistoryList(10L, null, 100, joinTime); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(after.getId(), result.get(0).getId()); + } + + // ========== selectListByGroupIdAndPendingReceipt ========== + + @Test + public void testSelectListByGroupIdAndPendingReceipt_basic() { + // 准备:待回执 + 已完成 + 无需回执,三种状态 + ImGroupMessageDO pending = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + pending.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(pending); + ImGroupMessageDO done = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + done.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus()); + mapper.insert(done); + ImGroupMessageDO noReceipt = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + noReceipt.setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + mapper.insert(noReceipt); + + List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); + + // 断言:只返回 pending 记录 + assertEquals(1, result.size()); + assertEquals(pending.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndPendingReceipt_windowAndRecall() { + // 准备:窗口外 + 撤回 + 窗口内 pending + ImGroupMessageDO belowRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + belowRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(belowRange); + ImGroupMessageDO inRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + inRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(inRange); + ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); + recalled.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(recalled); + + // 调用:minId = belowRange.id(排除), maxId = inRange.id(包含) + List result = mapper.selectListByGroupIdAndPendingReceipt( + 10L, belowRange.getId(), inRange.getId()); + + // 断言:只返回窗口内的非撤回 pending,即 inRange + assertEquals(1, result.size()); + assertEquals(inRange.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByGroupIdAndPendingReceipt_otherGroupExcluded() { + ImGroupMessageDO g10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); + g10.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(g10); + ImGroupMessageDO g20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); + g20.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); + mapper.insert(g20); + + List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); + assertEquals(1, result.size()); + assertEquals(g10.getId(), result.get(0).getId()); + } + + // ========== 工具方法 ========== + + private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { + return ImGroupMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId) + .groupId(groupId) + .type(0) + .content("{\"content\":\"test\"}") + .status(status.getStatus()) + .sendTime(LocalDateTime.now()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .build(); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java new file mode 100644 index 0000000..f49d18c --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java @@ -0,0 +1,198 @@ +package cn.iocoder.yudao.module.im.dal.mysql.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * IM 私聊消息 Mapper 单元测试 + * + * @author 芋道源码 + */ +public class ImPrivateMessageMapperTest extends BaseDbUnitTest { + + @Resource + private ImPrivateMessageMapper mapper; + + /** + * 一个足够早的时间作为 minSendTime,避免把测试数据过滤掉 + */ + private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); + + // ========== selectListByMinId ========== + + @Test + public void testSelectListByMinId() { + // 准备:用户 1 发给用户 2 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + // 用户 2 发给用户 1 + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + // 用户 3 发给用户 4(不相关) + ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用:用户 1 从 id=0 拉取 + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100); + + // 断言:只包含与用户 1 相关的 2 条 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> + m.getSenderId().equals(1L) || m.getReceiverId().equals(1L))); + } + + @Test + public void testSelectListByMinId_withMinId() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + + // 调用:从 msg1.id 之后拉取 + List result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100); + + // 断言:只有 msg2 + assertEquals(1, result.size()); + assertEquals(msg2.getId(), result.get(0).getId()); + } + + @Test + public void testSelectListByMinId_limitSize() { + // 准备:插入 3 条 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + + // 调用:limit 2 + List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testSelectListByMinId_sendTimeFilter() { + // 准备:一条落在窗口内,一条落在窗口外 + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + newMsg.setSendTime(LocalDateTime.now().minusDays(1)); + mapper.insert(newMsg); + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + oldMsg.setSendTime(LocalDateTime.now().minusDays(40)); + mapper.insert(oldMsg); + + // 调用:窗口起点 = 30 天前 + List result = mapper.selectListByMinId(1L, 0L, + LocalDateTime.now().minusDays(30), 100); + + // 断言:只返回窗口内的消息 + assertEquals(1, result.size()); + assertEquals(newMsg.getId(), result.get(0).getId()); + } + + // ========== selectHistoryList ========== + + @Test + public void testSelectHistoryList_basic() { + // 准备:用户 1 <-> 用户 2 的消息 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + // 用户 1 <-> 用户 3 的消息(不相关) + ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:只有与用户 2 对话的 2 条,按 id 倒序 + assertEquals(2, result.size()); + assertTrue(result.get(0).getId() > result.get(1).getId()); + } + + @Test + public void testSelectHistoryList_excludeRecall() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL); + mapper.insert(msg2); + + // 调用 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:撤回的不返回 + assertEquals(1, result.size()); + assertEquals(msg1.getId(), result.get(0).getId()); + } + + @Test + public void testSelectHistoryList_withMaxId() { + // 准备 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg1); + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg2); + ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + mapper.insert(msg3); + + // 调用:从 msg3 往前拉 + List result = mapper.selectHistoryList(1L, 2L, msg3.getId(), 100); + + // 断言:只有 msg1 和 msg2 + assertEquals(2, result.size()); + assertTrue(result.stream().allMatch(m -> m.getId() < msg3.getId())); + } + + @Test + public void testSelectHistoryList_limit() { + // 准备 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + + // 调用:limit 2 + List result = mapper.selectHistoryList(1L, 2L, null, 2); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testSelectHistoryList_bidirectional() { + // 准备:验证双向查询 + ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); // 对方发的 + mapper.insert(msg1); + + // 调用:以用户 1 的视角查 + List result = mapper.selectHistoryList(1L, 2L, null, 100); + + // 断言:能查到对方发来的消息 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getSenderId()); + } + + // ========== 工具方法 ========== + + private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { + return ImPrivateMessageDO.builder() + .clientMessageId("uuid-" + System.nanoTime()) + .senderId(senderId) + .receiverId(receiverId) + .type(0) + .content("{\"content\":\"test\"}") + .status(status.getStatus()) + .sendTime(LocalDateTime.now()) + .build(); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java new file mode 100644 index 0000000..c6b1856 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java @@ -0,0 +1,263 @@ +package cn.iocoder.yudao.module.im.service.friend; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * IM 好友关系 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImFriendServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImFriendServiceImpl friendService; + + @Mock + private ImFriendMapper imFriendMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ImWebSocketService imWebSocketService; + @Mock + private ImPrivateMessageService privateMessageService; + + // ========== isFriend ========== + + @Test + public void testIsFriend_enabled() { + ImFriendDO friend = ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + assertTrue(friendService.isFriend(1L, 2L)); + } + + @Test + public void testIsFriend_disabledOrAbsent() { + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + assertFalse(friendService.isFriend(1L, 2L)); + + ImFriendDO disabled = ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 3L)).thenReturn(disabled); + assertFalse(friendService.isFriend(1L, 3L)); + } + + // ========== updateFriend ========== + + @Test + public void testUpdateFriend_success() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setMuted(true); + ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) + .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); + + // 调用 + friendService.updateFriend(1L, reqVO); + + // 断言:更新了 muted 字段 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertTrue(captor.getValue().getMuted()); + // 断言:推送了好友更新通知 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testUpdateFriend_notFriend() { + // 准备 + ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); + reqVO.setFriendUserId(2L); + reqVO.setMuted(true); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.updateFriend(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + // ========== addFriend ========== + + @Test + public void testAddFriend_self() { + ServiceException exception = assertThrows(ServiceException.class, + () -> friendService.addFriend(1L, 1L)); + assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode()); + } + + @Test + public void testAddFriend_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) + .thenReturn(friendService); + + // 准备 + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(null); + + // 调用 + friendService.addFriend(1L, 2L); + + // 断言:校验对方存在 + verify(adminUserApi).validateUser(2L); + // 断言:双向插入 2 条好友关系 + verify(imFriendMapper, times(2)).insert(any(ImFriendDO.class)); + // 断言:发送提示消息 + 给双方各推送一次好友添加事件 + verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + } + + @Test + public void testAddFriend0_existingEnabledSkipsUpdate() { + // 准备:已存在且启用,第二步不应触发更新 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L); + + // 断言:不插入,也不更新 + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + } + + @Test + public void testAddFriend0_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应当恢复状态 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + // 调用 + friendService.addFriend0(1L, 2L); + + // 断言:更新 status 为 ENABLE + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(10L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); + } + + @Test + public void testAddFriend0_duplicateKeySwallowed() { + // 准备:mapper 抛并发冲突 + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); + when(imFriendMapper.insert(any(ImFriendDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用:应被吞掉,不抛异常 + friendService.addFriend0(1L, 2L); + + verify(imFriendMapper).insert(any(ImFriendDO.class)); + } + + // ========== deleteFriend ========== + + @Test + public void testDeleteFriend_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) + .thenReturn(friendService); + + ImFriendDO friend12 = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + ImFriendDO friend21 = ImFriendDO.builder().id(11L).userId(2L).friendUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend12); + when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(friend21); + + friendService.deleteFriend(1L, 2L); + + // 断言:双向更新为 DISABLE + verify(imFriendMapper, times(2)).updateById(any(ImFriendDO.class)); + // 断言:推送提示消息 + 双方各推送一次好友删除事件 + verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + } + + @Test + public void testDeleteFriend0_alreadyDisabled() { + // 准备:已经是 DISABLE,不再更新 + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend0(1L, 2L); + + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); + } + + @Test + public void testDeleteFriend0_enabledGetsDisabled() { + ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); + + friendService.deleteFriend0(1L, 2L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); + verify(imFriendMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDeleteTime()); + } + + // ========== 其它读方法 ========== + + @Test + public void testGetFriendList() { + List list = List.of( + ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .deleteTime(LocalDateTime.now()).build() + ); + when(imFriendMapper.selectListByUserId(1L)).thenReturn(list); + + List result = friendService.getFriendList(1L); + assertEquals(2, result.size()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java new file mode 100644 index 0000000..37a7250 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java @@ -0,0 +1,330 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DuplicateKeyException; + +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群成员 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupMemberServiceImpl groupMemberService; + + @Mock + private ImGroupMemberMapper groupMemberMapper; + @Mock + private ImWebSocketService webSocketService; + + // ========== addGroupMember ========== + + @Test + public void testAddGroupMember_newInsert() { + // 准备:成员记录不存在 + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + // 调用 + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + // 断言:执行了 insert,返回记录的 status 为 ENABLE + assertNotNull(result); + assertEquals(10L, result.getGroupId()); + assertEquals(1L, result.getUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + assertNotNull(result.getJoinTime()); + verify(groupMemberMapper).insert(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingEnabledReturns() { + // 准备:已存在且 ENABLE,只返回已有记录,不做其它操作 + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + when(groupMemberMapper.selectById(50L)).thenReturn(exists); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertEquals(50L, result.getId()); + verify(groupMemberMapper, never()).insert(any(ImGroupMemberDO.class)); + verify(groupMemberMapper, never()).updateById(any(ImGroupMemberDO.class)); + } + + @Test + public void testAddGroupMember_existingDisabledRecovers() { + // 准备:已存在且 DISABLE,应更新为 ENABLE + ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); + ImGroupMemberDO recovered = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectById(50L)).thenReturn(recovered); + + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getJoinTime()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + } + + @Test + public void testAddGroupMember_duplicateKeyFallsBackToSelect() { + // 准备:第一次 select 返回 null,insert 抛 DuplicateKey,再次 select 返回已插入记录 + ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted); + when(groupMemberMapper.insert(any(ImGroupMemberDO.class))) + .thenThrow(new DuplicateKeyException("concurrent insert")); + + // 调用:冲突后降级 select + ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); + + assertNotNull(result); + assertEquals(80L, result.getId()); + } + + // ========== addGroupMembers ========== + + @Test + public void testAddGroupMembers_mixedInsertAndUpdate() { + // 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE(恢复),用户 4 已存在且 ENABLE(跳过) + ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of(exist3, exist4)); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L)); + + // 断言:updates 只有用户 3;inserts 只有用户 2 + verify(groupMemberMapper).updateBatch(argThat((List list) -> + list.size() == 1 && list.get(0).getId().equals(30L) + && CommonStatusEnum.ENABLE.getStatus().equals(list.get(0).getStatus()))); + verify(groupMemberMapper).insertBatch(argThat((List list) -> + list.size() == 1 && list.get(0).getUserId().equals(2L))); + } + + @Test + public void testAddGroupMembers_allExisting_onlyUpdates() { + // 准备:传入的 3 个用户都已有记录(全部 DISABLE) → 只做 update,不做 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(), + ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper).updateBatch(anyList()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allNew_onlyInserts() { + // 准备:都不存在 → 只做 insert + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper, never()).updateBatch(anyList()); + verify(groupMemberMapper).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allExistingEnabled_nothingHappens() { + // 准备:都已存在且 ENABLE → 既不 update 也不 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L)); + + verify(groupMemberMapper, never()).updateBatch(anyList()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_batchInsertDuplicateFallback() { + // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + doThrow(new DuplicateKeyException("concurrent batch insert")) + .when(groupMemberMapper).insertBatch(anyList()); + // addGroupMember 单条兜底逻辑 + when(groupMemberMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); + + // 调用 + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + // 断言:降级为逐条调用 insert + verify(groupMemberMapper, times(2)).insert(any(ImGroupMemberDO.class)); + } + + // ========== validateMemberInGroup ========== + + @Test + public void testValidateMemberInGroup_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_disabledEqualsNotInGroup() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.validateMemberInGroup(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testValidateMemberInGroup_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberDO result = groupMemberService.validateMemberInGroup(10L, 1L); + assertEquals(50L, result.getId()); + } + + // ========== updateGroupMember ========== + + @Test + public void testUpdateGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + ImGroupMemberUpdateReqVO reqVO = new ImGroupMemberUpdateReqVO() + .setGroupId(10L).setMuted(true).setDisplayUserName("昵称"); + + groupMemberService.updateGroupMember(1L, reqVO); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertTrue(captor.getValue().getMuted()); + verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_success() { + ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); + + groupMemberService.removeGroupMember(10L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateById(captor.capture()); + assertEquals(50L, captor.getValue().getId()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMember_notInGroup() { + when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMemberService.removeGroupMember(10L, 1L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== removeGroupMembers ========== + + @Test + public void testRemoveGroupMembers_batch() { + groupMemberService.removeGroupMembers(10L, List.of(2L, 3L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndUserIdsAndStatus(eq(10L), anyCollection(), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + @Test + public void testRemoveGroupMembersByGroupId() { + groupMemberService.removeGroupMembersByGroupId(10L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); + verify(groupMemberMapper).updateByGroupIdAndStatus(eq(10L), + eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getQuitTime()); + } + + // ========== getActiveGroupMemberUserIdsByGroupId ========== + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() { + // 准备:3 个 ENABLE 成员 + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members); + + // 调用 + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + // 断言:只返回 userId、顺序保留 + assertEquals(List.of(1L, 2L, 3L), userIds); + } + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() { + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); + + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + assertTrue(userIds.isEmpty()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java new file mode 100644 index 0000000..a68b897 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -0,0 +1,518 @@ +package cn.iocoder.yudao.module.im.service.group; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; +import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupServiceImpl groupService; + + @Mock + private ImGroupMapper groupMapper; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImGroupMessageService groupMessageService; + @Mock + private ImWebSocketService webSocketService; + @Mock + private ImFriendService friendService; + @Mock + private AdminUserApi adminUserApi; + + // ========== createGroup ========== + + @Test + public void testCreateGroup_success() { + // 准备 + ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); + reqVO.setName("测试群"); + when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { + ImGroupDO group = invocation.getArgument(0); + group.setId(100L); + return 1; + }); + + // 调用 + ImGroupDO result = groupService.createGroup(reqVO, 1L); + + // 断言:群主 + 状态 + assertEquals(100L, result.getId()); + assertEquals(1L, result.getOwnerUserId()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); + // 验证:群主加入群 + 推送群创建事件 + verify(groupMemberService).addGroupMember(100L, 1L); + verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + + // ========== updateGroup ========== + + @Test + public void testUpdateGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:当前用户不是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.updateGroup(reqVO, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + @Test + public void testUpdateGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("旧名字").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); + + ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); + reqVO.setId(10L); + reqVO.setName("新名字"); + + // 调用 + ImGroupDO result = groupService.updateGroup(reqVO, 1L); + + // 断言:更新了数据库 + verify(groupMapper).updateById(any(ImGroupDO.class)); + assertEquals("新名字", result.getName()); + // 推送给所有成员 + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== dissolveGroup ========== + + @Test + public void testDissolveGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); + AdminUserRespDTO user = new AdminUserRespDTO(); + user.setId(1L); + user.setNickname("群主"); + when(adminUserApi.getUser(1L)).thenReturn(user); + + // 调用 + groupService.dissolveGroup(10L, 1L); + + // 断言:群状态变为 DISABLE + 群成员全部移除 + 清理已读缓存 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupDO.class); + verify(groupMapper).updateById(captor.capture()); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); + assertNotNull(captor.getValue().getDissolvedTime()); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + verify(groupMessageService).deleteReadMaxMessageIdMap(10L); + // 发送解散提示消息 + 推送群删除事件 + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testDissolveGroup_notOwner() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.dissolveGroup(10L, 1L)); + assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); + } + } + + // ========== inviteGroupMember ========== + + @Test + public void testInviteGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群存在 + 当前用户是群成员 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 当前成员只有群主 + List activeMembers = new ArrayList<>(); + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + // 被邀请人 2 和 3 都是好友 + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + List friends = List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(friends); + + // 用户昵称 + AdminUserRespDTO u1 = new AdminUserRespDTO(); u1.setId(1L); u1.setNickname("群主"); + AdminUserRespDTO u2 = new AdminUserRespDTO(); u2.setId(2L); u2.setNickname("张三"); + AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); + Map userMap23 = new HashMap<>(); + userMap23.put(2L, u2); + userMap23.put(3L, u3); + Map userMap1 = Map.of(1L, u1); + when(adminUserApi.getUserMap(List.of(2L, 3L))).thenReturn(userMap23); + when(adminUserApi.getUserMap(List.of(1L))).thenReturn(userMap1); + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:校验群成员 + 批量添加成员 + 推送事件 + 发送提示消息 + verify(groupMemberService).validateMemberInGroup(10L, 1L); + verify(groupMemberService).addGroupMembers(eq(10L), anyCollection()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + } + } + + @Test + public void testInviteGroupMember_notFriend() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) + .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); + // 只有 2 是好友,3 不是 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build())); + AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_INVITE_NOT_FRIEND.getCode(), exception.getCode()); + } + } + + @Test + public void testInviteGroupMember_memberExceed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群 10 已有 499 人(逼近 MAX_GROUP_MEMBER=500),再邀请 2 人 → 501 超限 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + List activeMembers = new ArrayList<>(); + for (long i = 1; i <= 499; i++) { + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(i) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + } + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); + // 被邀请人都是好友 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(600L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(601L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_MEMBER_EXCEED.getCode(), exception.getCode()); + // 断言:不加成员、不推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + } + } + + @Test + public void testInviteGroupMember_skipsMembersAlreadyInGroup() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + // 用户 2 已在群中 + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中 + + // 调用 + groupService.inviteGroupMember(1L, reqVO); + + // 断言:不会触发添加、不会推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + verify(webSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== quitGroup ========== + + @Test + public void testQuitGroup_ownerCannotQuit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.quitGroup(10L, 1L)); + assertEquals(GROUP_OWNER_CANNOT_QUIT.getCode(), exception.getCode()); + } + } + + @Test + public void testQuitGroup_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + groupService.quitGroup(10L, 1L); + + verify(groupMemberService).removeGroupMember(10L, 1L); + verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== removeGroupMember ========== + + @Test + public void testRemoveGroupMember_cannotRemoveSelf() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(1L, 2L)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.removeGroupMember(1L, reqVO)); + assertEquals(GROUP_CANNOT_REMOVE_SELF.getCode(), exception.getCode()); + } + } + + @Test + public void testRemoveGroupMember_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(List.of(2L, 3L)); + + groupService.removeGroupMember(1L, reqVO); + + verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); + verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection()); + verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); + verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + } + + // ========== validateGroupExists ========== + + @Test + public void testValidateGroupExists_notExists() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + when(groupMapper.selectById(10L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_NOT_EXISTS.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_banned() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L).banned(true) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_BANNED.getCode(), exception.getCode()); + } + } + + @Test + public void testValidateGroupExists_dissolved() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + ImGroupDO group = ImGroupDO.builder().id(10L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.validateGroupExists(10L)); + assertEquals(GROUP_DISSOLVED.getCode(), exception.getCode()); + } + } + + // ========== getMyGroupList ========== + + @Test + public void testGetMyGroupList_noMembers() { + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>()); + + List result = groupService.getMyGroupList(1L); + assertTrue(result.isEmpty()); + verify(groupMapper, never()).selectByIds(anyCollection()); + } + + @Test + public void testGetMyGroupList_success() { + // 活跃群成员 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(20L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ))); + // 最近退群成员(最近 30 天内) + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>(List.of( + ImGroupMemberDO.builder().groupId(30L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ))); + List groups = List.of( + ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMapper.selectByIds(anyCollection())).thenReturn(groups); + + List result = groupService.getMyGroupList(1L); + assertEquals(3, result.size()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java new file mode 100644 index 0000000..9fa3d9d --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -0,0 +1,1063 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; +import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; +import cn.iocoder.yudao.module.im.service.group.ImGroupService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 群聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImGroupMessageServiceImpl groupMessageService; + + @Mock + private ImGroupMessageMapper groupMessageMapper; + @Mock + private ImGroupService groupService; + @Mock + private ImGroupMemberService groupMemberService; + @Mock + private ImSensitiveWordService sensitiveWordService; + @Mock + private GroupMessageReadRedisDAO groupMessageReadRedisDAO; + @Mock + private ImWebSocketService imWebSocketService; + + private ImGroupMessageSendReqVO buildSendReqVO() { + ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-group-001"); + reqVO.setGroupId(10L); + reqVO.setType(0); // TEXT + reqVO.setContent("{\"content\":\"群聊你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + group.setName("测试群"); + when(groupService.validateGroupExists(10L)).thenReturn(group); + + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { + ImGroupMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(10L, result.getGroupId()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); + + // 验证推送给 3 个群成员(含发送者自己,用于多端同步) + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + ImGroupMessageDO existing = ImGroupMessageDO.builder() + .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L) + .type(0).content("{\"content\":\"群聊你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(existing); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言 + assertEquals(100L, result.getId()); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + } + + @Test + public void testSendMessage_notInGroup() { + // 准备 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_joinTimeFilter() { + // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(2); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + // 入群前 1 条 + 入群后 1 条 + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(beforeJoin, afterJoin)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:入群前消息不可见 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() { + // 准备:首批 2 条(入群前 + 定向给别人)全部不可见,第二批命中可见消息 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(1); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(2L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusHours(2)).build(); + ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder() + .id(3L).groupId(10L).senderId(2L) + .sendTime(now.minusMinutes(30)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(beforeJoin, directedMsg)); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(2L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(visibleMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); + + // 断言:仅返回第二批的可见消息;两批 selectListByMinId 各被调用一次 + assertEquals(1, result.size()); + assertEquals(3L, result.get(0).getId()); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2)); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(2L), + any(LocalDateTime.class), eq(2)); + } + + @Test + public void testPullMessages_quitMemberLoadsPreQuitMessages() { + // 准备:当前群(在群)+ 一个最近退群 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO activeMsg = ImGroupMessageDO.builder() + .id(5L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(2)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(activeMsg)); + + // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime) + LocalDateTime quitTime = now.minusDays(3); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(20)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() + .id(3L).groupId(20L).senderId(99L) + .sendTime(now.minusDays(5)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), + any(LocalDateTime.class), eq(quitTime), eq(100))) + .thenReturn(List.of(quitGroupMsg)); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:两条消息均返回,并按 id 升序 + assertEquals(2, result.size()); + assertEquals(3L, result.get(0).getId()); + assertEquals(5L, result.get(1).getId()); + } + + @Test + public void testPullMessages_quitMemberFilteredByBoundarySendTime() { + // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime + LocalDateTime now = LocalDateTime.now(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); + + LocalDateTime boundarySendTime = now.minusDays(2); + ImGroupMessageDO boundary = ImGroupMessageDO.builder() + .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build(); + when(groupMessageMapper.selectById(8L)).thenReturn(boundary); + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 8L, 100); + + // 断言:使用边界消息 sendTime 抬升 minQuitTime + assertEquals(0, result.size()); + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); + } + + @Test + public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() { + // 准备:主查询首批不可见触发重试(游标从 8→101),退群补齐仍使用原始 minId=8 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime joinTime = now.minusHours(1); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L) + .sendTime(now.minusHours(3)).build(); + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(101L).groupId(10L).senderId(2L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusMinutes(50)).build(); + ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder() + .id(102L).groupId(10L).senderId(2L) + .sendTime(now.minusMinutes(30)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(8L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(beforeJoin, directedMsg)); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(101L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(visibleActiveMsg)); + + LocalDateTime boundarySendTime = now.minusDays(2); + ImGroupMessageDO boundary = ImGroupMessageDO.builder() + .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build(); + when(groupMessageMapper.selectById(8L)).thenReturn(boundary); + + LocalDateTime quitTime = now.minusHours(12); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(10)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() + .id(50L).groupId(20L).senderId(99L) + .sendTime(now.minusDays(1)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), + any(LocalDateTime.class), eq(quitTime), eq(2))) + .thenReturn(List.of(quitGroupMsg)); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 8L, 2); + + // 断言:退群补齐使用原始 minId=8 而非主查询重试后的 101 + assertEquals(2, result.size()); + assertEquals(50L, result.get(0).getId()); + assertEquals(102L, result.get(1).getId()); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(8L), + any(LocalDateTime.class), eq(2)); + verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(101L), + any(LocalDateTime.class), eq(2)); + verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); + verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), + any(LocalDateTime.class), eq(quitTime), eq(2)); + verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L), + any(LocalDateTime.class), eq(quitTime), eq(2)); + } + + @Test + public void testPullMessages_receiverUserIdsFilter() { + // 准备 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + // 定向接收消息:只给用户 2 和 3 + ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(5L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(now.minusHours(2)).build(); + // 全员消息 + ImGroupMessageDO allMsg = ImGroupMessageDO.builder() + .id(2L).groupId(10L).senderId(5L) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(directedMsg, allMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:定向接收的消息用户 1 看不到,只能看到全员消息 + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getId()); + } + + @Test + public void testPullMessages_atFieldCorrectReturn() { + // 准备 + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO atMsg = ImGroupMessageDO.builder() + .id(1L).groupId(10L).senderId(2L) + .atUserIds(List.of(1L, 3L)) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(atMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 调用 + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + // 断言:@ 字段正确返回 + assertEquals(1, result.size()); + assertEquals(List.of(1L, 3L), result.get(0).getAtUserIds()); + } + + @Test + public void testPullMessages_pageTrimmedToSize() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO activeMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); + + ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder() + .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build(); + ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder() + .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(2))) + .thenReturn(List.of(activeMsg1, activeMsg2)); + + LocalDateTime quitTime = now.minusDays(1); + ImGroupMemberDO quitMember = ImGroupMemberDO.builder() + .groupId(20L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(20)) + .quitTime(quitTime).build(); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of(quitMember)); + + ImGroupMessageDO quit1 = ImGroupMessageDO.builder() + .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build(); + ImGroupMessageDO quit2 = ImGroupMessageDO.builder() + .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build(); + when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), + any(LocalDateTime.class), eq(quitTime), eq(2))) + .thenReturn(List.of(quit1, quit2)); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); + + assertEquals(2, result.size()); + assertEquals(150L, result.get(0).getId()); + assertEquals(160L, result.get(1).getId()); + } + + @Test + public void testPullMessages_pullSetsReadFromRedisCursor() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO low = ImGroupMessageDO.builder() + .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(now.minusHours(2)).build(); + ImGroupMessageDO high = ImGroupMessageDO.builder() + .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(low, high)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + assertEquals(2, result.size()); + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); + } + + @Test + public void testPullMessages_receiptReadCountForSender() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(10)).build(); + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); + + ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .sendTime(now.minusHours(1)).build(); + when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), + any(LocalDateTime.class), eq(100))) + .thenReturn(List.of(receiptMsg)); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(List.of()); + + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 100L); + positions.put(3L, 50L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + List allMembers = List.of( + member, + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(20)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(now.minusDays(20)).build() + ); + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + + List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); + + assertEquals(1, result.size()); + assertEquals(1, result.get(0).getReadCount()); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:消息由用户 1 发送,刚发送 5 分钟内 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + // 撤回前需要校验用户仍在群中 + ImGroupMemberDO selfMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(selfMember); + when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); + + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L)); + + // 调用 + ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); + + // 断言:返回 TIP_TEXT 消息 + assertNotNull(result); + // 验证:更新原消息状态 + 插入 tipMessage + verify(groupMessageMapper).updateById(any(ImGroupMessageDO.class)); + verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); + // 验证:给 2 个活跃成员推送撤回提示 + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(1L) && ids.contains(2L)), + any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testRecallMessage_notOwn() { + // 准备 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(2L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_alreadyRecalled() { + // 准备 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.RECALL.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_timeout() { + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); + } + + // ========== 群已读测试 ========== + + @Test + public void testReadMessages_notInGroup() { + // 准备:当前用户不在群中 + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + @Test + public void testGetReadUsers_withVisibleScope() { + // 准备:用户 1 是群成员 + ImGroupMemberDO currentMember = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(currentMember); + + // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(80L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); + when(groupMessageMapper.selectById(80L)).thenReturn(message); + + // 准备:群成员一览 + // 用户 1: 正常,入群在消息之前 + // 用户 2: 正常,入群在消息之前 + // 用户 3: 正常,但入群在消息之后 → 不可见 + // 用户 5: 发送者,不计入回执 + List allMembers = List.of( + currentMember, + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群 + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 + ); + when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); + + // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 50L); + positions.put(3L, 200L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + // 调用:查询 messageId=80 的已读用户 + List readUsers = groupMessageService.getGroupReadUsers(1L, 10L, 80L); + + // 断言: + // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ + // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ + // 用户 3: 入群在消息之后 → 不可见 → 不算 + // 用户 5: 发送者 → 排除 + assertEquals(1, readUsers.size()); + assertTrue(readUsers.contains(1L)); + assertFalse(readUsers.contains(2L)); + assertFalse(readUsers.contains(3L)); + assertFalse(readUsers.contains(5L)); + } + + @Test + public void testGetReadUsers_notInGroup() { + // 准备:当前用户不在群中 + when(groupMemberService.validateMemberInGroup(10L, 99L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:越权校验 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupReadUsers(99L, 10L, 80L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + } + + // ========== 发送:补充边界 ========== + + @Test + public void testSendMessage_receiptPending() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:reqVO.receipt=true + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setReceipt(true); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言:receipt=true → 回执状态为 PENDING + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); + } + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + + // ========== 撤回:补充边界 ========== + + @Test + public void testRecallMessage_notExists() { + when(groupMessageMapper.selectById(50L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_senderNotInGroup() { + // 准备:消息合法、可撤回时间内,但发送人已退群 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不执行更新、不插 tip + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + } + + // ========== 已读:happy path + 短路 ========== + + @Test + public void testReadGroupMessages_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:用户在群;Redis 游标从 5 前进到 100 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); + // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:Redis 游标更新 + READ 事件 + verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); + verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testReadGroupMessages_cursorAlreadyAhead() { + // 准备:已读游标已 >= 目标,直接短路返回 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:不更新、不推送 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); + } + + // ========== 回执:DONE 状态迁移 ========== + + @Test + public void testReadGroupMessageEvent_receiptDoneTransition() { + // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} + // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 100L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + // 调用 + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:消息回执状态更新为 DONE + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); + // 断言:给消息发送方(5)推送 RECEIPT 事件 + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + @Test + public void testReadGroupMessageEvent_receiptStaysPendingWhenPartialRead() { + // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 50L); // 还没读到 100 + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:回执状态保持 PENDING,不更新 DB + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + // 仍然推送 RECEIPT 事件给发送方(携带已读人数) + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== + + @Test + public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { + // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 4 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { + // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} + // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && !ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { + // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) + // 预期:仅定向用户可见 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(99L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + + // ========== sendTipGroupMessage ========== + + @Test + public void testSendTipGroupMessage_success() { + // 准备 + Set targets = Set.of(1L, 2L, 3L); + + // 调用 + groupMessageService.sendTipGroupMessage(1L, 10L, targets, "张三加入了群聊"); + + // 断言:插入一条 TIP_TEXT 提示消息 + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(msgCaptor.capture()); + ImGroupMessageDO tip = msgCaptor.getValue(); + assertEquals(1L, tip.getSenderId()); + assertEquals(10L, tip.getGroupId()); + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); + assertEquals("张三加入了群聊", tip.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), tip.getReceiptStatus()); + assertNotNull(tip.getClientMessageId()); + assertNotNull(tip.getSendTime()); + // 断言:推送给指定用户集合 + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); + } + + // ========== getGroupMessageList ========== + + @Test + public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { + // 准备 + LocalDateTime joinTime = LocalDateTime.now().minusDays(5); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + + List mockList = List.of( + ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L).build() + ); + when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); + + // 调用 + List result = groupMessageService.getGroupMessageList(1L, reqVO); + + // 断言:使用成员的 joinTime 作为历史消息的下限 + assertEquals(1, result.size()); + verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); + } + + @Test + public void testGetGroupMessageList_notInGroup() { + // 准备:用户不在群中 + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setLimit(20); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), + GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:鉴权失败 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupMessageList(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不查询 DB + verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); + } + + // ========== delete read cursor 委托 ========== + + @Test + public void testDeleteReadMaxMessageId_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageId(10L, 1L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); + } + + @Test + public void testDeleteReadMaxMessageIds_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + } + + @Test + public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIdMap(10L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java new file mode 100644 index 0000000..ae40b55 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -0,0 +1,356 @@ +package cn.iocoder.yudao.module.im.service.message; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.friend.ImFriendService; +import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * IM 私聊消息 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImPrivateMessageServiceImpl privateMessageService; + + @Mock + private ImPrivateMessageMapper privateMessageMapper; + @Mock + private ImFriendService friendService; + @Mock + private ImSensitiveWordService sensitiveWordService; + @Mock + private ImWebSocketService imWebSocketService; + + private ImPrivateMessageSendReqVO buildSendReqVO() { + ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); + reqVO.setClientMessageId("test-uuid-001"); + reqVO.setReceiverId(2L); + reqVO.setType(0); // TEXT + reqVO.setContent("{\"content\":\"你好\"}"); + return reqVO; + } + + // ========== 发送测试 ========== + + @Test + public void testSendMessage_success() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(friendService.isFriend(1L, 2L)).thenReturn(true); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { + ImPrivateMessageDO msg = invocation.getArgument(0); + msg.setId(99L); + return 1; + }); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); + + // 断言 + assertNotNull(result); + assertEquals(1L, result.getSenderId()); + assertEquals(2L, result.getReceiverId()); + assertEquals(0, result.getType()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertNotNull(result.getSendTime()); + + // 验证调用 + verify(friendService).isFriend(1L, 2L); + verify(sensitiveWordService).validateText(reqVO.getContent()); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送给接收方和发送方 + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testSendMessage_clientMessageIdIdempotent() { + // 准备:模拟已存在消息 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder() + .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L) + .type(0).content("{\"content\":\"你好\"}").status(0) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(existingMessage); + + // 调用 + ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); + + // 断言:返回已存在的消息 + assertEquals(100L, result.getId()); + // 验证不会重复插入 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + } + + @Test + public void testSendMessage_notFriend() { + // 准备 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(friendService.isFriend(1L, 2L)).thenReturn(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); + assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); + } + + // ========== pull 测试 ========== + + @Test + public void testPullMessages_success() { + // 准备 + List mockMessages = List.of( + ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() + ); + when(privateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) + .thenReturn(mockMessages); + + // 调用 + List result = privateMessageService.pullPrivateMessageList(1L, 0L, 100); + + // 断言 + assertEquals(2, result.size()); + } + + @Test + public void testPullMessages_sizeExceeded() { + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.pullPrivateMessageList(1L, 0L, 1001)); + assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode()); + } + + // ========== 已读测试 ========== + + @Test + public void testReadMessages_success() { + // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5) + List unreadMessages = List.of( + ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), + ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build() + ); + when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L); + + // 断言:更新了消息状态 + verify(privateMessageMapper).updateByIdsAndStatus(eq(List.of(1L, 5L)), + eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); + + // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(ImPrivateMessageDTO.class); + verify(imWebSocketService, times(2)).sendPrivateMessageAsync( + userCaptor.capture(), contentCaptor.capture()); + + // 第一次:发给自己的 READ 事件 + assertEquals(1L, userCaptor.getAllValues().get(0)); + ImPrivateMessageDTO readPayload = contentCaptor.getAllValues().get(0); + assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); + assertEquals(1L, readPayload.getSenderId()); + assertEquals(2L, readPayload.getReceiverId()); + assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); + + // 第二次:发给对方的 RECEIPT 事件 + assertEquals(2L, userCaptor.getAllValues().get(1)); + ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1); + assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); + assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); + } + + // ========== 撤回测试 ========== + + @Test + public void testRecallMessage_success() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 + when(privateMessageMapper.selectById(10L)).thenReturn(message); + when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); + when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); + + // 调用 + ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L); + + // 断言:返回 TIP_TEXT 消息 + assertNotNull(result); + // 验证:更新原消息状态 + 插入 tipMessage + verify(privateMessageMapper).updateById(any(ImPrivateMessageDO.class)); + verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); + // 验证推送了消息(给接收方和发送方) + verify(imWebSocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testRecallMessage_notOwn() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(2L).receiverId(1L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_alreadyRecalled() { + // 准备 + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.RECALL.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_notExists() { + // 准备 + when(privateMessageMapper.selectById(10L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_timeout() { + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); + // 断言:不推送、不插 tipMessage + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(friendService.isFriend(1L, 2L)).thenReturn(true); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testReadMessages_noUnread() { + // 准备:没有未读消息 + when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(List.of()); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L); + + // 断言:不更新、不推送 + verify(privateMessageMapper, never()).updateByIdsAndStatus(anyList(), anyInt(), any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ========== sendTipPrivateMessage ========== + + @Test + public void testSendTipPrivateMessage_success() { + // 调用 + privateMessageService.sendTipPrivateMessage(1L, 2L, "好友添加成功"); + + // 断言:插入一条 TIP_TEXT 提示消息 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); + verify(privateMessageMapper).insert(captor.capture()); + ImPrivateMessageDO tip = captor.getValue(); + assertEquals(1L, tip.getSenderId()); + assertEquals(2L, tip.getReceiverId()); + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); + assertEquals("好友添加成功", tip.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); + assertNotNull(tip.getClientMessageId()); + assertNotNull(tip.getSendTime()); + // 断言:给双方各推送一次 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + // ========== getPrivateMessageList ========== + + @Test + public void testGetPrivateMessageList_delegatesToMapper() { + // 准备 + ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO(); + reqVO.setReceiverId(2L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + List mockList = List.of( + ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build() + ); + when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList); + + // 调用 + List result = privateMessageService.getPrivateMessageList(1L, reqVO); + + // 断言:透传到 mapper,参数一致 + assertEquals(2, result.size()); + verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java new file mode 100644 index 0000000..49b264b --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.im.service.sensitiveword; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; +import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * IM 敏感词 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImSensitiveWordServiceImpl sensitiveWordService; + + @Mock + private ImSensitiveWordMapper imSensitiveWordMapper; + + @BeforeEach + public void setUp() { + // 模拟 @PostConstruct 初始化:加载 2 个启用的敏感词 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(1L).word("badword") + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImSensitiveWordDO.builder().id(2L).word("违禁词") + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + sensitiveWordService.init(); + } + + @Test + public void testValidateText_null() { + // null 直接返回,不抛异常 + assertDoesNotThrow(() -> sensitiveWordService.validateText(null)); + } + + @Test + public void testValidateText_empty() { + assertDoesNotThrow(() -> sensitiveWordService.validateText("")); + } + + @Test + public void testValidateText_clean() { + // 正常文本不应命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); + } + + @Test + public void testValidateText_hitEnglish() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("this contains badword here")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testValidateText_hitChinese() { + ServiceException exception = assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + } + + @Test + public void testRefresh_reloadsFromMapper() { + // 刷新前:init 调用 mapper 一次 + verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + + // 准备:新列表不含原词 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) + .thenReturn(List.of( + ImSensitiveWordDO.builder().id(3L).word("new").status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + // 调用:刷新 + sensitiveWordService.refresh(); + + // 断言:原敏感词不再命中,新词命中 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword")); + assertThrows(ServiceException.class, + () -> sensitiveWordService.validateText("contains new")); + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java new file mode 100644 index 0000000..aa42d35 --- /dev/null +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java @@ -0,0 +1,173 @@ +package cn.iocoder.yudao.module.im.service.websocket; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * IM WebSocket 推送 Service 单元测试 + * + * @author 芋道源码 + */ +public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ImWebSocketServiceImpl imWebSocketService; + + @Mock + private WebSocketMessageSender webSocketMessageSender; + + @AfterEach + public void tearDown() { + // 清理事务同步上下文,避免串扰其它用例 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + // ========== sendPrivateMessageAsync ========== + + @Test + public void testSendPrivateMessageAsync_noTransactionSendsImmediately() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用:无事务,应立即发送 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendPrivateMessageAsync_inTransactionDeferredUntilCommit() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:开启事务同步 + TransactionSynchronizationManager.initSynchronization(); + try { + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + + // 调用 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + // 断言:事务未提交,未推送 + verify(webSocketMessageSender, never()).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 模拟事务提交 + List syncs = + TransactionSynchronizationManager.getSynchronizations(); + assertEquals(1, syncs.size()); + syncs.forEach(TransactionSynchronization::afterCommit); + + // 断言:提交后推送 + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); + } finally { + TransactionSynchronizationManager.clear(); + } + } + } + + // ========== sendGroupMessageAsync ========== + + @Test + public void testSendGroupMessageAsync_fanOutToAllUsers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + dto.setSenderId(1L); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(3L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + + @Test + public void testSendGroupMessageAsync_senderExceptionDoesNotBreakOthers() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + // 给 1 号用户推送时抛异常,不能影响 2/3 号 + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), eq(1L), anyString(), any()); + + imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); + + // 2L 和 3L 也都被推送 + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + verify(webSocketMessageSender).sendObject(anyInt(), eq(3L), anyString(), any()); + } + } + + @Test + public void testSendPrivateMessageAsync_exceptionSwallowed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:sender 抛异常 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 调用:异常应被吞掉,不向上抛 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + } + } + + @Test + public void testSendGroupMessageAsync_singleUserDefaultOverload() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + ImGroupMessageDTO dto = new ImGroupMessageDTO(); + dto.setGroupId(10L); + + imWebSocketService.sendGroupMessageAsync(42L, dto); + + verify(webSocketMessageSender).sendObject( + eq(UserTypeEnum.ADMIN.getValue()), eq(42L), eq(ImGroupMessageDTO.TYPE), eq(dto)); + } + } + +} diff --git a/yudao-module-im/src/test/resources/application-unit-test.yaml b/yudao-module-im/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000..b7bb631 --- /dev/null +++ b/yudao-module-im/src/test/resources/application-unit-test.yaml @@ -0,0 +1,49 @@ +spring: + main: + lazy-initialization: true # 开启懒加载,加快速度 + banner-mode: off # 单元测试,禁用 Banner + +--- #################### 数据库相关配置 #################### + +spring: + # 数据源配置项 + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 + initial-size: 1 # 单元测试,配置为 1,提升启动速度 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + encoding: UTF-8 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 16379 # 端口(单元测试,使用 16379 端口) + database: 0 # 数据库索引 + +mybatis: + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + +--- #################### 定时任务相关配置 #################### + +--- #################### 配置中心相关配置 #################### + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项(单元测试,禁用 Lock4j) + +--- #################### 监控相关配置 #################### + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-im/src/test/resources/sql/clean.sql b/yudao-module-im/src/test/resources/sql/clean.sql new file mode 100644 index 0000000..9101fff --- /dev/null +++ b/yudao-module-im/src/test/resources/sql/clean.sql @@ -0,0 +1,5 @@ +DELETE FROM "im_private_message"; +DELETE FROM "im_group_message"; +DELETE FROM "im_group"; +DELETE FROM "im_group_member"; +DELETE FROM "im_friend"; diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000..924c93c --- /dev/null +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -0,0 +1,94 @@ +CREATE TABLE IF NOT EXISTS "im_private_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', + "sender_id" bigint NOT NULL COMMENT '发送人编号', + "receiver_id" bigint NOT NULL COMMENT '接收人编号', + "type" tinyint NOT NULL COMMENT '消息类型', + "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', + "status" tinyint NOT NULL COMMENT '消息状态', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 私聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', + "sender_id" bigint NOT NULL COMMENT '发送人编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "type" tinyint NOT NULL COMMENT '消息类型', + "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', + "status" tinyint NOT NULL COMMENT '消息状态', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '定向接收用户编号列表', + "at_user_ids" varchar(1024) DEFAULT NULL COMMENT '@ 目标用户编号列表', + "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群聊消息表'; + +CREATE TABLE IF NOT EXISTS "im_group" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "name" varchar(64) NOT NULL COMMENT '群名称', + "owner_user_id" bigint NOT NULL COMMENT '群主用户编号', + "avatar" varchar(512) DEFAULT NULL COMMENT '群头像', + "notice" varchar(2048) DEFAULT NULL COMMENT '群公告', + "banned" bit DEFAULT FALSE COMMENT '是否封禁', + "banned_reason" varchar(512) DEFAULT NULL COMMENT '封禁原因', + "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', + "status" tinyint NOT NULL COMMENT '群状态', + "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群信息表'; + +CREATE TABLE IF NOT EXISTS "im_group_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "group_id" bigint NOT NULL COMMENT '群编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "display_user_name" varchar(64) DEFAULT NULL COMMENT '组内显示名', + "display_group_name" varchar(64) DEFAULT NULL COMMENT '群显示备注', + "muted" bit DEFAULT FALSE COMMENT '是否免打扰', + "status" tinyint NOT NULL COMMENT '成员状态', + "join_time" timestamp DEFAULT NULL COMMENT '入群时间', + "quit_time" timestamp DEFAULT NULL COMMENT '退群时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 群成员表'; + +CREATE TABLE IF NOT EXISTS "im_friend" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "user_id" bigint NOT NULL COMMENT '用户编号', + "friend_user_id" bigint NOT NULL COMMENT '好友用户编号', + "muted" bit DEFAULT FALSE COMMENT '是否免打扰', + "status" tinyint NOT NULL COMMENT '好友状态', + "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间', + "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 好友关系表'; diff --git a/yudao-module-im/yudao-module-im-api/pom.xml b/yudao-module-im/yudao-module-im-api/pom.xml deleted file mode 100644 index 871a6ef..0000000 --- a/yudao-module-im/yudao-module-im-api/pom.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - cn.iocoder.boot - yudao-module-im - ${revision} - - 4.0.0 - yudao-module-im-api - jar - - ${project.artifactId} - - im 模块 API,暴露给其它模块调用 - - - - - cn.iocoder.boot - yudao-common - - - - \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java deleted file mode 100644 index 4938097..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/api/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @author anhaohao - * @since 2024/3/9 下午8:59 - */ -package cn.iocoder.yudao.module.im.api; \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java deleted file mode 100644 index db6d4ef..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.iocoder.yudao.module.im.enums; - -import cn.iocoder.yudao.framework.common.exception.ErrorCode; - -/** - * IM 错误码枚举类 - *

- * im 系统,使用 1-040-000-000 段 - */ -public interface ErrorCodeConstants { - - // ========== 消息 (1-040-300-000) ========== - ErrorCode MESSAGE_NOT_EXISTS = new ErrorCode(1_040_300_000, "消息不存在"); - ErrorCode MESSAGE_RECALL_DENIED = new ErrorCode(1_040_300_002, "只能撤回自己发送的消息"); - ErrorCode MESSAGE_ALREADY_RECALLED = new ErrorCode(1_040_300_003, "消息已撤回"); - ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送"); - ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); - ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); - - // ========== 群 (1-040-400-000) ========== - ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); - ErrorCode GROUP_BANNED = new ErrorCode(1_040_400_001, "群已被封禁"); - ErrorCode GROUP_DISSOLVED = new ErrorCode(1_040_400_002, "群已解散"); - ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); - - // ========== 群成员 (1-040-500-000) ========== - ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); - ErrorCode GROUP_OWNER_CANNOT_QUIT = new ErrorCode(1_040_500_003, "群主不能退出群聊,请先转让群主或解散群聊"); - ErrorCode GROUP_CANNOT_REMOVE_SELF = new ErrorCode(1_040_500_004, "不能将自己移出群聊"); - ErrorCode GROUP_MEMBER_EXCEED = new ErrorCode(1_040_500_005, "群聊人数不能超过 {} 人"); - ErrorCode GROUP_INVITE_NOT_FRIEND = new ErrorCode(1_040_500_006, "'{}' 不是您的好友,邀请失败"); - - // ========== 好友 (1-040-600-000) ========== - ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友"); - ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友"); - -} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java deleted file mode 100644 index 924b96f..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java +++ /dev/null @@ -1,72 +0,0 @@ -package cn.iocoder.yudao.module.im.enums; - -/** - * IM 通用常量 - * - * @author 芋道源码 - */ -public interface ImCommonConstants { - - /** - * pull 最大拉取数量 - */ - int MESSAGE_MAX_PULL_SIZE = 1000; - - /** - * 群最大成员人数 - */ - int MAX_GROUP_MEMBER = 500; - - /** - * 消息撤回时间限制(分钟) - */ - int MESSAGE_RECALL_TIMEOUT_MINUTES = 5; - - /** - * 私聊离线消息最大拉取天数 - *

- * 客户端通过 pull 接口增量拉取私聊离线消息时,仅返回最近 N 天内产生的消息, - * 超过该窗口的老消息不再主动推送(可通过历史消息接口按需倒翻)。 - */ - int MESSAGE_PRIVATE_PULL_MAX_DAYS = 30; - - /** - * 群聊离线消息最大拉取天数 - *

- * 客户端通过 pull 接口增量拉取群聊离线消息时,仅返回最近 N 天内产生的消息; - * 退群前消息的补齐也以该窗口为基准(早于窗口的退群群不再扫描),避免老用户首次 - * 拉取时对历史退群群做大量查询。 - */ - int MESSAGE_GROUP_PULL_MAX_DAYS = 30; - - // ==================== 好友相关提示消息 ==================== - - /** - * 好友添加系统提示 - */ - String FRIEND_ADD_TIP_MESSAGE = "你们已成为好友,现在可以开始聊天了"; - /** - * 好友删除系统提示 - */ - String FRIEND_DELETE_TIP_MESSAGE = "你们的好友关系已被解除"; - - // ==================== 群相关提示消息 ==================== - - /** - * 群解散系统提示 - */ - String GROUP_DISSOLVE_TIP_MESSAGE = "\"{}\"解散了群聊"; - /** - * 退群系统提示 - */ - String GROUP_QUIT_TIP_MESSAGE = "你已退出群聊"; - /** - * 踢出群系统提示 - */ - String GROUP_REMOVE_TIP_MESSAGE = "你已被移出群聊"; - /** - * 邀请入群系统提示 - */ - String GROUP_INVITE_TIP_MESSAGE = "\"{}\"邀请\"{}\"加入了群聊"; - -} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java deleted file mode 100644 index 256560c..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java +++ /dev/null @@ -1,39 +0,0 @@ -package cn.iocoder.yudao.module.im.enums.message; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Arrays; - -/** - * IM 群消息回执状态枚举 - * - * @author 芋道源码 - */ -@Getter -@AllArgsConstructor -public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable { - - NO_RECEIPT(0, "不需要回执"), - PENDING(1, "待完成"), - DONE(2, "已完成"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); - - /** - * 状态 - */ - private final Integer status; - - /** - * 名字 - */ - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java deleted file mode 100644 index 1332287..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageSceneEnum.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.iocoder.yudao.module.im.enums.message; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -/** - * IM 消息场景枚举 - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -@Getter -public enum ImMessageSceneEnum implements ArrayValuable { - - PRIVATE(1, "私聊"), - GROUP(2, "群聊"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageSceneEnum::getScene).toArray(Integer[]::new); - - /** - * 场景 - */ - private final Integer scene; - /** - * 名字 - */ - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java deleted file mode 100644 index 79cb8d8..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java +++ /dev/null @@ -1,42 +0,0 @@ -package cn.iocoder.yudao.module.im.enums.message; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -/** - * IM 消息状态枚举 - *

- * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3) - * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2) - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -@Getter -public enum ImMessageStatusEnum implements ArrayValuable { - - SENDING(-1, "发送中"), // 仅客户端使用 - UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态) - RECALL(2, "已撤回"), - READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现 - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new); - - /** - * 状态 - */ - private final Integer status; - /** - * 名字 - */ - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java deleted file mode 100644 index 8fad3b1..0000000 --- a/yudao-module-im/yudao-module-im-api/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java +++ /dev/null @@ -1,57 +0,0 @@ -package cn.iocoder.yudao.module.im.enums.message; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Arrays; - -/** - * IM 消息类型枚举 - * - * @author 芋道源码 - */ -@Getter -@AllArgsConstructor -public enum ImMessageTypeEnum implements ArrayValuable { - - TEXT(0, "文本"), // 对应 TextMessage 类 - IMAGE(1, "图片"), // 对应 ImageMessage 类 - FILE(2, "文件"), // 对应 FileMessage 类 - VOICE(3, "语音"), // 对应 AudioMessage 类 - VIDEO(4, "视频"), // 对应 VideoMessage 类 - - RECALL(10, "撤回"), // 对应 RecallMessage 类 - READ(11, "已读"), // 暂无 - RECEIPT(12, "回执"), // 暂无 - TIP_TEXT(21, "提示文本"), // 对应 TextMessage 类 - - // ==================== 非单聊、群聊存储的类型,100 开始 ==================== - - FRIEND_ADD(100, "好友添加"), // 暂无 - FRIEND_DELETE(101, "好友删除"), // 暂无 - FRIEND_UPDATE(102, "好友更新"), // 暂无(客户端收到后自行拉取) - - GROUP_CREATE(200, "群创建"), // 暂无 - GROUP_UPDATE(201, "群信息变更"), // 暂无 - GROUP_DELETE(202, "群删除"), // 暂无(解散/退群/踢出均用此类型) - GROUP_MEMBER_UPDATE(203, "群成员信息变更"); // 暂无(客户端收到后自行拉取) - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); - - /** - * 类型 - */ - private final Integer type; - - /** - * 名字 - */ - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/pom.xml b/yudao-module-im/yudao-module-im-biz/pom.xml deleted file mode 100644 index 4f51712..0000000 --- a/yudao-module-im/yudao-module-im-biz/pom.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - yudao-module-im - cn.iocoder.boot - ${revision} - - 4.0.0 - jar - - yudao-module-im-biz - - ${project.artifactId} - - im 模块,主要实现 im 模块的业务逻辑。 - - - - - cn.iocoder.boot - yudao-module-im-api - ${revision} - - - cn.iocoder.boot - yudao-module-system - ${revision} - - - - - cn.iocoder.boot - yudao-spring-boot-starter-web - - - - cn.iocoder.boot - yudao-spring-boot-starter-security - - - - cn.iocoder.boot - yudao-spring-boot-starter-websocket - - - - - cn.iocoder.boot - yudao-spring-boot-starter-mybatis - - - - cn.iocoder.boot - yudao-spring-boot-starter-redis - - - - - cn.iocoder.boot - yudao-spring-boot-starter-excel - - - - - cn.iocoder.boot - yudao-spring-boot-starter-test - - - - - \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java deleted file mode 100644 index 146cb54..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java +++ /dev/null @@ -1,108 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.friend; - -import cn.hutool.core.collection.CollUtil; -import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.util.collection.MapUtils; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; -import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; -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 jakarta.validation.constraints.NotNull; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.singleton; -import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - IM 好友") -@RestController -@RequestMapping("/im/friend") -@Validated -public class ImFriendController { - - @Resource - private ImFriendService friendService; - - @Resource - private AdminUserApi adminUserApi; - - @GetMapping("/list") - @Operation(summary = "获得当前登录用户的好友列表") - public CommonResult> getMyFriendList() { - List friends = friendService.getFriendList(getLoginUserId()); - return success(buildFriendRespVOList(friends)); - } - - @GetMapping("/get") - @Operation(summary = "获得好友详情") - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") - public CommonResult getFriend(@RequestParam("friendUserId") Long friendUserId) { - ImFriendDO friend = friendService.getFriend(getLoginUserId(), friendUserId); - return success(buildFriendRespVO(friend)); - } - - @PostMapping("/add") - @Operation(summary = "添加好友(双向建立关系)") - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") - public CommonResult addFriend( - @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { - friendService.addFriend(getLoginUserId(), friendUserId); - return success(true); - } - - @DeleteMapping("/delete") - @Operation(summary = "删除好友(双向软删除)") - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") - public CommonResult deleteFriend( - @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { - friendService.deleteFriend(getLoginUserId(), friendUserId); - return success(true); - } - - @PutMapping("/update") - @Operation(summary = "更新好友信息(当前仅免打扰)") - public CommonResult updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) { - friendService.updateFriend(getLoginUserId(), reqVO); - return success(true); - } - - // ========== 私有方法:VO 组装 ========== - - private List buildFriendRespVOList(Collection friends) { - if (CollUtil.isEmpty(friends)) { - return Collections.emptyList(); - } - // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1 - Map userMap = adminUserApi.getUserMap( - convertList(friends, ImFriendDO::getFriendUserId)); - return convertList(friends, f -> { - ImFriendRespVO vo = BeanUtils.toBean(f, ImFriendRespVO.class); - MapUtils.findAndThen(userMap, f.getFriendUserId(), user -> - vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); - return vo; - }); - } - - private ImFriendRespVO buildFriendRespVO(ImFriendDO friend) { - if (friend == null) { - return null; - } - return CollUtil.getFirst(buildFriendRespVOList(singleton(friend))); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java deleted file mode 100644 index 8e00c39..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java +++ /dev/null @@ -1,43 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.friend.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * IM 好友 Response VO - * - * @author 芋道源码 - */ -@Schema(description = "管理后台 - IM 好友 Response VO") -@Data -public class ImFriendRespVO { - - @Schema(description = "关系记录编号", example = "1024") - private Long id; - - @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") - private Long friendUserId; - - @Schema(description = "是否免打扰", example = "false") - private Boolean muted; - - @Schema(description = "好友状态(0=正常,1=已删除)", example = "0") - private Integer status; - - @Schema(description = "添加好友时间") - private LocalDateTime addTime; - - @Schema(description = "删除好友时间") - private LocalDateTime deleteTime; - - // ========== 下面是聚合字段,方便前端显示 ========== - - @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道") - private String nickname; - - @Schema(description = "好友头像(实时聚合自 AdminUser)") - private String avatar; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java deleted file mode 100644 index a44ae83..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java +++ /dev/null @@ -1,19 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.friend.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -@Schema(description = "管理后台 - IM 好友更新 Request VO") -@Data -public class ImFriendUpdateReqVO { - - @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") - @NotNull(message = "好友用户编号不能为空") - private Long friendUserId; - - @Schema(description = "是否免打扰", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") - @NotNull(message = "免打扰状态不能为空") - private Boolean muted; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java deleted file mode 100644 index 4867775..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java +++ /dev/null @@ -1,107 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group; - -import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupRespVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.service.group.ImGroupService; -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 java.util.List; - -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - 群") -@RestController -@RequestMapping("/im/group") -@Validated -public class ImGroupController { - - @Resource - private ImGroupService groupService; - - // ==================== 群的写操作 ==================== - - @PostMapping("/create") - @Operation(summary = "创建群") - @PreAuthorize("@ss.hasPermission('im:group:create')") - public CommonResult createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { - ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); - return success(BeanUtils.toBean(group, ImGroupRespVO.class)); - } - - @PutMapping("/update") - @Operation(summary = "更新群") - @PreAuthorize("@ss.hasPermission('im:group:update')") - public CommonResult updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { - ImGroupDO group = groupService.updateGroup(updateReqVO, getLoginUserId()); - return success(BeanUtils.toBean(group, ImGroupRespVO.class)); - } - - @DeleteMapping("/dissolve") - @Operation(summary = "解散群") - @Parameter(name = "id", description = "群编号", required = true) - @PreAuthorize("@ss.hasPermission('im:group:delete')") - public CommonResult dissolveGroup(@RequestParam("id") Long id) { - groupService.dissolveGroup(id, getLoginUserId()); - return success(true); - } - - // ==================== 群的读操作 ==================== - - @GetMapping("/get") - @Operation(summary = "获得群") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('im:group:query')") - public CommonResult getGroup(@RequestParam("id") Long id) { - ImGroupDO group = groupService.getGroup(id); - return success(BeanUtils.toBean(group, ImGroupRespVO.class)); - } - - @GetMapping("/list") - @Operation(summary = "获得当前登录用户的群列表") - public CommonResult> getMyGroupList() { - List groups = groupService.getMyGroupList(getLoginUserId()); - return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); - } - - // ==================== 群成员的写操作 ==================== - - @PostMapping("/invite") - @Operation(summary = "邀请用户加入群") - @PreAuthorize("@ss.hasPermission('im:group:update')") - public CommonResult inviteGroupMember(@Valid @RequestBody ImGroupMemberInviteReqVO inviteReqVO) { - groupService.inviteGroupMember(getLoginUserId(), inviteReqVO); - return success(true); - } - - @DeleteMapping("/quit") - @Operation(summary = "退出群") - @Parameter(name = "groupId", description = "群编号", required = true) - @PreAuthorize("@ss.hasPermission('im:group:update')") - public CommonResult quitGroup(@RequestParam("groupId") Long groupId) { - groupService.quitGroup(groupId, getLoginUserId()); - return success(true); - } - - @DeleteMapping("/kicking") - @Operation(summary = "移除群成员") - @PreAuthorize("@ss.hasPermission('im:group:update')") - public CommonResult removeGroupMember(@Valid @RequestBody ImGroupMemberRemoveReqVO removeReqVO) { - groupService.removeGroupMember(getLoginUserId(), removeReqVO); - return success(true); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java deleted file mode 100644 index c967166..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java +++ /dev/null @@ -1,86 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group; - -import cn.hutool.core.collection.CollUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.util.collection.MapUtils; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRespVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; -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 java.util.List; -import java.util.Map; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; -import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; - -@Tag(name = "管理后台 - 群成员") -@RestController -@RequestMapping("/im/group-member") -@Validated -public class ImGroupMemberController { - - @Resource - private ImGroupMemberService groupMemberService; - - @Resource - private AdminUserApi adminUserApi; - - @PutMapping("/update") - @Operation(summary = "更新群成员") - @PreAuthorize("@ss.hasPermission('im:group-member:update')") - public CommonResult updateGroupMember(@Valid @RequestBody ImGroupMemberUpdateReqVO updateReqVO) { - groupMemberService.updateGroupMember(getLoginUserId(), updateReqVO); - return success(true); - } - - @GetMapping("/get") - @Operation(summary = "获得群成员") - @Parameter(name = "id", description = "编号", required = true, example = "1024") - @PreAuthorize("@ss.hasPermission('im:group-member:query')") - public CommonResult getGroupMember(@RequestParam("id") Long id) { - ImGroupMemberDO groupMember = groupMemberService.getGroupMember(id); - return success(BeanUtils.toBean(groupMember, ImGroupMemberRespVO.class)); - } - - @GetMapping("/list") - @Operation(summary = "获得指定群的成员列表") - @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") - public CommonResult> getGroupMemberList(@RequestParam("groupId") Long groupId) { - // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) - // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 - List members = groupMemberService.getGroupMemberListByGroupId(groupId); - // 1.2 校验当前登录用户是否为群的有效成员,非成员不可查看 - Long loginUserId = getLoginUserId(); - if (CollUtil.findOne(members, m -> loginUserId.equals(m.getUserId()) - && CommonStatusEnum.ENABLE.getStatus().equals(m.getStatus())) == null) { - throw exception(GROUP_MEMBER_NOT_IN_GROUP); - } - - // 2.批量聚合 AdminUser 信息(昵称 / 头像) - Map userMap = adminUserApi.getUserMap( - convertList(members, ImGroupMemberDO::getUserId)); - return success(convertList(members, m -> { - ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); - MapUtils.findAndThen(userMap, m.getUserId(), user -> - vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); - return vo; - })); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java deleted file mode 100644 index 37fa337..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java +++ /dev/null @@ -1,15 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import lombok.Data; - -@Schema(description = "管理后台 - 群创建 Request VO") -@Data -public class ImGroupCreateReqVO { - - @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群") - @NotBlank(message = "群名称不能为空") - private String name; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java deleted file mode 100644 index fdb6016..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupPageReqVO.java +++ /dev/null @@ -1,30 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo; - -import lombok.*; -import io.swagger.v3.oas.annotations.media.Schema; -import cn.iocoder.yudao.framework.common.pojo.PageParam; -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 ImGroupPageReqVO extends PageParam { - - @Schema(description = "群名称", example = "芋艿") - private String name; - - @Schema(description = "群主用户编号", example = "31460") - private Long ownerUserId; - - @Schema(description = "群公告") - private String notice; - - @Schema(description = "创建时间") - @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) - private LocalDateTime[] createTime; - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java deleted file mode 100644 index 037daf0..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java +++ /dev/null @@ -1,42 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; - -import java.time.LocalDateTime; - -@Schema(description = "管理后台 - 群 Response VO") -@Data -public class ImGroupRespVO { - - @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") - private Long id; - - @Schema(description = "群名称", example = "芋艿") - private String name; - - @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") - private Long ownerUserId; - - @Schema(description = "群头像") - private String avatar; - - @Schema(description = "群公告") - private String notice; - - @Schema(description = "是否封禁") - private Boolean banned; - - @Schema(description = "封禁时间") - private LocalDateTime bannedTime; - - @Schema(description = "群状态", requiredMode = Schema.RequiredMode.REQUIRED) - private Integer status; - - @Schema(description = "解散时间") - private LocalDateTime dissolvedTime; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) - private LocalDateTime createTime; - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java deleted file mode 100644 index 0275fd8..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupSaveReqVO.java +++ /dev/null @@ -1,27 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import jakarta.validation.constraints.*; - -@Schema(description = "管理后台 - 群新增/修改 Request VO") -@Data -public class ImGroupSaveReqVO { - - @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") - private Long id; - - @Schema(description = "群名称", example = "芋艿") - private String name; - - @Schema(description = "群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "31460") - @NotNull(message = "群主用户编号不能为空") - private Long ownerUserId; - - @Schema(description = "群头像") - private String avatar; - - @Schema(description = "群公告") - private String notice; - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java deleted file mode 100644 index d423d50..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java +++ /dev/null @@ -1,24 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -@Schema(description = "管理后台 - 群更新 Request VO") -@Data -public class ImGroupUpdateReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1003") - @NotNull(message = "群编号不能为空") - private Long id; - - @Schema(description = "群名称", example = "芋道技术交流群") - private String name; - - @Schema(description = "群头像") - private String avatar; - - @Schema(description = "群公告") - private String notice; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java deleted file mode 100644 index a486190..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberCreateReqVO.java +++ /dev/null @@ -1,19 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -@Schema(description = "管理后台 - 群成员邀请 Request VO") -@Data -public class ImGroupMemberCreateReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") - @NotNull(message = "用户编号不能为空") - private Long userId; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java deleted file mode 100644 index 12cd09d..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberInviteReqVO.java +++ /dev/null @@ -1,22 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.util.List; - -@Schema(description = "管理后台 - 群成员邀请 Request VO") -@Data -public class ImGroupMemberInviteReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "被邀请的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") - @NotEmpty(message = "被邀请的用户编号列表不能为空") - private List memberUserIds; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java deleted file mode 100644 index 9862b12..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRemoveReqVO.java +++ /dev/null @@ -1,22 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.util.List; - -@Schema(description = "管理后台 - 群成员移除 Request VO") -@Data -public class ImGroupMemberRemoveReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "被移除的用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]") - @NotEmpty(message = "被移除的用户编号列表不能为空") - private List memberUserIds; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java deleted file mode 100644 index 1827e29..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberRespVO.java +++ /dev/null @@ -1,50 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -@Schema(description = "管理后台 - 群成员 Response VO") -@Data -public class ImGroupMemberRespVO { - - @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "17071") - private Long id; - - @Schema(description = "群编号", example = "13279") - private Long groupId; - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "21730") - private Long userId; - - @Schema(description = "组内显示名", example = "芋艿") - private String displayUserName; - - @Schema(description = "群显示备注", example = "核心群") - private String displayGroupName; - - @Schema(description = "是否免打扰") - private Boolean muted; - - @Schema(description = "成员状态", example = "0") - private Integer status; - - @Schema(description = "入群时间") - private LocalDateTime joinTime; - - @Schema(description = "退群时间") - private LocalDateTime quitTime; - - @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) - private LocalDateTime createTime; - - // ========== 关联 AdminUser 的字段 ========== - - @Schema(description = "用户昵称", example = "芋道") - private String nickname; - - @Schema(description = "用户头像") - private String avatar; - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java deleted file mode 100644 index 3e31cb9..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/member/ImGroupMemberUpdateReqVO.java +++ /dev/null @@ -1,26 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.group.vo.member; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; -import lombok.experimental.Accessors; - -@Schema(description = "管理后台 - 群成员更新 Request VO") -@Data -@Accessors(chain = true) -public class ImGroupMemberUpdateReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "群内昵称", example = "芋头") - private String displayUserName; - - @Schema(description = "群名备注", example = "公司群") - private String displayGroupName; - - @Schema(description = "是否免打扰") - private Boolean muted; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java deleted file mode 100644 index 26bffe7..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java +++ /dev/null @@ -1,83 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message; - -import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; -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.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - IM 群聊消息") -@RestController -@RequestMapping("/im/message/group") -@Validated -public class ImGroupMessageController { - - @Resource - private ImGroupMessageService groupMessageService; - - @PostMapping("/send") - @Operation(summary = "发送群聊消息") - public CommonResult sendGroupMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) { - ImGroupMessageDO message = groupMessageService.sendGroupMessage(getLoginUserId(), reqVO); - return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); - } - - @GetMapping("/pull") - @Operation(summary = "拉取群聊消息(增量)") - @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") - @Parameter(name = "size", description = "拉取数量", required = true, example = "100") - public CommonResult> pullGroupMessageList(@RequestParam("minId") Long minId, - @RequestParam("size") Integer size) { - List messages = groupMessageService.pullGroupMessageList(getLoginUserId(), minId, size); - return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); - } - - @PutMapping("/read") - @Operation(summary = "标记群聊消息已读") - @Parameter(name = "groupId", description = "群编号", required = true, example = "1") - @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100") - public CommonResult readGroupMessages(@RequestParam("groupId") Long groupId, - @RequestParam("messageId") Long messageId) { - groupMessageService.readGroupMessages(getLoginUserId(), groupId, messageId); - return success(true); - } - - @DeleteMapping("/recall") - @Operation(summary = "撤回群聊消息") - @Parameter(name = "id", description = "消息编号", required = true, example = "1") - public CommonResult recallGroupMessage(@RequestParam("id") Long id) { - ImGroupMessageDO message = groupMessageService.recallGroupMessage(getLoginUserId(), id); - return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); - } - - @GetMapping("/read-users") - @Operation(summary = "获取群消息已读用户列表") - @Parameter(name = "groupId", description = "群编号", required = true, example = "1") - @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") - public CommonResult> getGroupReadUsers(@RequestParam("groupId") Long groupId, - @RequestParam("messageId") Long messageId) { - return success(groupMessageService.getGroupReadUsers(getLoginUserId(), groupId, messageId)); - } - - @GetMapping("/list") - @Operation(summary = "查询群聊历史消息") - public CommonResult> getGroupMessageList(@Valid ImGroupMessageListReqVO reqVO) { - List messages = groupMessageService.getGroupMessageList(getLoginUserId(), reqVO); - return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http deleted file mode 100644 index 83d1773..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImMessageController.http +++ /dev/null @@ -1,18 +0,0 @@ -### 请求 /send 接口 => 成功 -POST {{baseUrl}}/im/message/send -Authorization: Bearer {{token}} -Content-Type: application/json -tenant-id: {{adminTenentId}} - -{ - "clientMessageId": "123", - "receiverId": 1, - "conversationType": 1, - "contentType": 101, - "content": "你好1" -} - -### 请求 /pull 接口 => 成功 -GET {{baseUrl}}/im/message/pull?sequence=0&size=2 -Authorization: Bearer {{token}} -tenant-id: {{adminTenentId}} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java deleted file mode 100644 index f6b25d6..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java +++ /dev/null @@ -1,73 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message; - -import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageRespVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; -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.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; - -@Tag(name = "管理后台 - IM 私聊消息") -@RestController -@RequestMapping("/im/message/private") -@Validated -public class ImPrivateMessageController { - - @Resource - private ImPrivateMessageService privateMessageService; - - @PostMapping("/send") - @Operation(summary = "发送私聊消息") - public CommonResult sendPrivateMessage( - @Valid @RequestBody ImPrivateMessageSendReqVO reqVO) { - ImPrivateMessageDO message = privateMessageService.sendPrivateMessage(getLoginUserId(), reqVO); - return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); - } - - @GetMapping("/pull") - @Operation(summary = "拉取私聊消息(增量)") - @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") - @Parameter(name = "size", description = "拉取数量", required = true, example = "100") - public CommonResult> pullPrivateMessageList(@RequestParam("minId") Long minId, - @RequestParam("size") Integer size) { - List messages = privateMessageService.pullPrivateMessageList(getLoginUserId(), minId, size); - return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); - } - - @PutMapping("/read") - @Operation(summary = "标记私聊消息已读") - @Parameter(name = "receiverId", description = "接收方用户编号(对方)", required = true, example = "2") - public CommonResult readPrivateMessages(@RequestParam("receiverId") Long receiverId) { - privateMessageService.readPrivateMessages(getLoginUserId(), receiverId); - return success(true); - } - - @DeleteMapping("/recall") - @Operation(summary = "撤回私聊消息") - @Parameter(name = "id", description = "消息编号", required = true, example = "1") - public CommonResult recallPrivateMessage(@RequestParam("id") Long id) { - ImPrivateMessageDO message = privateMessageService.recallPrivateMessage(getLoginUserId(), id); - return success(BeanUtils.toBean(message, ImPrivateMessageRespVO.class)); - } - - @GetMapping("/list") - @Operation(summary = "查询私聊历史消息") - public CommonResult> getPrivateMessageList(@Valid ImPrivateMessageListReqVO reqVO) { - List messages = privateMessageService.getPrivateMessageList(getLoginUserId(), reqVO); - return success(BeanUtils.toBean(messages, ImPrivateMessageRespVO.class)); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java deleted file mode 100644 index 76aa958..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java +++ /dev/null @@ -1,26 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -@Schema(description = "管理后台 - 群聊历史消息列表 Request VO") -@Data -public class ImGroupMessageListReqVO { - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") - private Long maxId; - - @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") - @NotNull(message = "拉取数量不能为空") - @Min(value = 1, message = "拉取数量最小值为 1") - @Max(value = 200, message = "拉取数量最大值为 200") - private Integer limit; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java deleted file mode 100644 index ee8a164..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java +++ /dev/null @@ -1,52 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 群聊消息 Response VO - */ -@Schema(description = "管理后台 - 群聊消息 Response VO") -@Data -public class ImGroupMessageRespVO { - - @Schema(description = "消息编号", example = "1") - private Long id; - - @Schema(description = "客户端消息编号", example = "uuid-xxx") - private String clientMessageId; - - @Schema(description = "发送人编号", example = "1") - private Long senderId; - - @Schema(description = "群编号", example = "1") - private Long groupId; - - @Schema(description = "消息类型", example = "0") - private Integer type; - - @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") - private String content; - - @Schema(description = "消息状态", example = "0") - private Integer status; - - @Schema(description = "发送时间") - private LocalDateTime sendTime; - - @Schema(description = "@目标用户编号列表", example = "[1,2,3]") - private List atUserIds; - - @Schema(description = "定向接收用户编号列表", example = "[1,2]") - private List receiverUserIds; - - @Schema(description = "回执状态", example = "0") - private Integer receiptStatus; - - @Schema(description = "已读人数(回执消息、且发送人为当前用户时有值)", example = "3") - private Integer readCount; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java deleted file mode 100644 index 2e493a9..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java +++ /dev/null @@ -1,39 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.util.List; - -/** - * 群聊消息发送 Request VO - */ -@Schema(description = "管理后台 - 群聊消息发送 Request VO") -@Data -public class ImGroupMessageSendReqVO { - - @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") - @NotEmpty(message = "客户端消息编号不能为空") - private String clientMessageId; - - @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - @NotNull(message = "群编号不能为空") - private Long groupId; - - @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - @NotNull(message = "消息类型不能为空") - private Integer type; - - @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") - @NotEmpty(message = "消息内容不能为空") - private String content; - - @Schema(description = "@目标用户编号列表", example = "[1,2,3]") - private List atUserIds; - - @Schema(description = "是否需要回执", example = "false") - private Boolean receipt; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java deleted file mode 100644 index c9259d9..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageListReqVO.java +++ /dev/null @@ -1,27 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "管理后台 - 私聊历史消息列表 Request VO") -@Data -public class ImPrivateMessageListReqVO { - - @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") - @NotNull(message = "接收人编号不能为空") - private Long receiverId; - - @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") - private Long maxId; - - @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") - @NotNull(message = "拉取数量不能为空") - @Min(value = 1, message = "拉取数量最小值为 1") - @Max(value = 200, message = "拉取数量最大值为 200") - private Integer limit; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java deleted file mode 100644 index b353e77..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessagePageReqVO.java +++ /dev/null @@ -1,21 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; - -import cn.iocoder.yudao.framework.common.pojo.PageParam; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.ToString; - -import jakarta.validation.constraints.NotNull; - -@Schema(description = "管理后台 - 私聊消息分页 Request VO") -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -public class ImPrivateMessagePageReqVO extends PageParam { - - @Schema(description = "接收人编号(对方)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") - @NotNull(message = "接收人编号不能为空") - private Long receiverId; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java deleted file mode 100644 index 3484b12..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java +++ /dev/null @@ -1,39 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 私聊消息 Response VO - */ -@Schema(description = "管理后台 - 私聊消息 Response VO") -@Data -public class ImPrivateMessageRespVO { - - @Schema(description = "消息编号", example = "1") - private Long id; - - @Schema(description = "客户端消息编号", example = "uuid-xxx") - private String clientMessageId; - - @Schema(description = "发送人编号", example = "1") - private Long senderId; - - @Schema(description = "接收人编号", example = "2") - private Long receiverId; - - @Schema(description = "消息类型", example = "0") - private Integer type; - - @Schema(description = "消息内容", example = "{\"content\":\"你好\"}") - private String content; - - @Schema(description = "消息状态", example = "0") - private Integer status; - - @Schema(description = "发送时间") - private LocalDateTime sendTime; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java deleted file mode 100644 index cff487a..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java +++ /dev/null @@ -1,31 +0,0 @@ -package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -/** - * 私聊消息发送 Request VO - */ -@Schema(description = "管理后台 - 私聊消息发送 Request VO") -@Data -public class ImPrivateMessageSendReqVO { - - @Schema(description = "客户端消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "uuid-xxx") - @NotEmpty(message = "客户端消息编号不能为空") - private String clientMessageId; - - @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") - @NotNull(message = "接收人编号不能为空") - private Long receiverId; - - @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - @NotNull(message = "消息类型不能为空") - private Integer type; - - @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") - @NotEmpty(message = "消息内容不能为空") - private String content; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java deleted file mode 100644 index c61f0e1..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java +++ /dev/null @@ -1,70 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.friend; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * IM 好友关系 DO - *

- * 业务语义: - * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A) - * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除 - * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知 - * - * @author 芋道源码 - */ -@TableName("im_friend") -@KeySequence("im_friend_seq") -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImFriendDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 用户编号 - *

- * 关联 AdminUserDO 的 id 字段 - */ - private Long userId; - /** - * 好友用户编号 - *

- * 关联 AdminUserDO 的 id 字段 - */ - private Long friendUserId; - /** - * 是否免打扰 - */ - private Boolean muted; - /** - * 好友状态 - *

- * 枚举 {@link CommonStatusEnum} - */ - private Integer status; - /** - * 添加好友时间 - */ - private LocalDateTime addTime; - /** - * 删除好友时间 - *

- * 不为 null 时表示已删除 - */ - private LocalDateTime deleteTime; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java deleted file mode 100644 index c304938..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java +++ /dev/null @@ -1,74 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.group; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * IM 群信息 DO - * - * @author 芋道源码 - */ -@TableName("im_group") -@KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImGroupDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 群名称 - */ - private String name; - /** - * 群主用户编号 - *

- * 关联 AdminUserDO 的 id 字段 - */ - private Long ownerUserId; - /** - * 群头像 - */ - private String avatar; - /** - * 群公告 - */ - private String notice; - /** - * 是否封禁 - */ - private Boolean banned; - /** - * 封禁原因 - */ - private String bannedReason; - /** - * 封禁时间 - */ - private LocalDateTime bannedTime; - /** - * 群状态 - *

- * 枚举 {@link CommonStatusEnum} - * ENABLE = 正常,DISABLE = 已解散 - */ - private Integer status; - /** - * 解散时间 - */ - private LocalDateTime dissolvedTime; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java deleted file mode 100644 index cd275f9..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupMemberDO.java +++ /dev/null @@ -1,71 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.group; - -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * IM 群成员 DO - * - * @author 芋道源码 - */ -@TableName("im_group_member") -@KeySequence("im_group_member_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImGroupMemberDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 群编号 - *

- * 关联 {@link ImGroupDO#getId()} - */ - private Long groupId; - /** - * 用户编号 - *

- * 关联 AdminUserDO 的 id 字段 - */ - private Long userId; - /** - * 组内显示名 - */ - private String displayUserName; - /** - * 群显示备注 - */ - private String displayGroupName; - /** - * 是否免打扰 - */ - private Boolean muted; - /** - * 成员状态 - *

- * 枚举 {@link CommonStatusEnum} - */ - private Integer status; - /** - * 入群时间 - */ - private LocalDateTime joinTime; - /** - * 退群时间 - */ - private LocalDateTime quitTime; - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java deleted file mode 100644 index f6c3d60..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java +++ /dev/null @@ -1,109 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.message; - -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * IM 群聊消息 DO - * - * @author 芋道源码 - */ -@TableName(value = "im_group_message", autoResultMap = true) -@KeySequence("im_group_message_seq") -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImGroupMessageDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 客户端消息编号,用于发送幂等 - */ - private String clientMessageId; - /** - * 发送人编号 - * - * 关联 AdminUserDO 的 id 字段 - */ - private Long senderId; - /** - * 群编号 - * - * 关联 {@link ImGroupDO#getId()} - */ - private Long groupId; - /** - * 消息类型 - *

- * 枚举 {@link ImMessageTypeEnum} - */ - private Integer type; - /** - * 消息内容,JSON 格式 - * - * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 - */ - private String content; - /** - * 消息状态 - *

- * 枚举 {@link ImMessageStatusEnum} - * - * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型) - */ - private Integer status; - /** - * 发送时间 - */ - private LocalDateTime sendTime; - /** - * 定向接收用户编号列表,以逗号分隔 - *

- * 为空表示全员可见 - * - * 关联 AdminUserDO 的 id 字段 - */ - @TableField(typeHandler = LongListTypeHandler.class) - private List receiverUserIds; - /** - * @ 目标用户编号列表,以逗号分隔 - * - * 关联 AdminUserDO 的 id 字段 - */ - @TableField(typeHandler = LongListTypeHandler.class) - private List atUserIds; - /** - * 回执状态 - *

- * 枚举 {@link ImGroupMessageReceiptStatusEnum} - */ - private Integer receiptStatus; - - // ========== 非表字段 ========== - - /** - * 离线拉取等场景下回算的已读人数 - */ - @TableField(exist = false) - private Integer readCount; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java deleted file mode 100644 index bb87939..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java +++ /dev/null @@ -1,72 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.message; - -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * IM 私聊消息 DO - * - * @author 芋道源码 - */ -@TableName("im_private_message") -@KeySequence("im_private_message_seq") -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImPrivateMessageDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 客户端消息编号,用于发送幂等 - */ - private String clientMessageId; - /** - * 发送人编号 - * - * 关联 AdminUserDO 的 id 字段 - */ - private Long senderId; - /** - * 接收人编号 - * - * 关联 AdminUserDO 的 id 字段 - */ - private Long receiverId; - /** - * 消息类型 - *

- * 枚举 {@link ImMessageTypeEnum} - */ - private Integer type; - /** - * 消息内容,JSON 格式 - * - * 参考 content 包下的 TextMessage、ImageMessage 等结构化模型 - */ - private String content; - /** - * 消息状态 - *

- * 枚举 {@link ImMessageStatusEnum} - */ - private Integer status; - /** - * 发送时间 - */ - private LocalDateTime sendTime; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java deleted file mode 100644 index 4f6d1fc..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/sensitiveword/ImSensitiveWordDO.java +++ /dev/null @@ -1,41 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -/** - * IM 敏感词 DO - * - * @author 芋道源码 - */ -@TableName("im_sensitive_word") -@KeySequence("im_sensitive_word_seq") -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ImSensitiveWordDO extends BaseDO { - - /** - * 编号 - */ - @TableId - private Long id; - /** - * 敏感词 - */ - private String word; - /** - * 状态 - *

- * 枚举 {@link CommonStatusEnum} - */ - private Integer status; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java deleted file mode 100644 index dc716f0..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java +++ /dev/null @@ -1,46 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.friend; - -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import org.apache.ibatis.annotations.Mapper; - -import java.util.Collection; -import java.util.List; - -/** - * IM 好友关系 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImFriendMapper extends BaseMapperX { - - default ImFriendDO selectByUserIdAndFriendUserId(Long userId, Long friendUserId) { - return selectOne(new LambdaQueryWrapperX() - .eq(ImFriendDO::getUserId, userId) - .eq(ImFriendDO::getFriendUserId, friendUserId)); - } - - default List selectListByUserId(Long userId) { - return selectList(new LambdaQueryWrapperX() - .eq(ImFriendDO::getUserId, userId) - .orderByDesc(ImFriendDO::getId)); - } - default List selectListByUserIdAndStatus(Long userId, Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImFriendDO::getUserId, userId) - .eq(ImFriendDO::getStatus, status) - .orderByDesc(ImFriendDO::getId)); - } - - default List selectListByUserIdAndFriendUserIdsAndStatus(Long userId, - Collection friendUserIds, - Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImFriendDO::getUserId, userId) - .in(ImFriendDO::getFriendUserId, friendUserIds) - .eq(ImFriendDO::getStatus, status)); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java deleted file mode 100644 index 8861658..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.group; - -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import org.apache.ibatis.annotations.Mapper; - -/** - * IM 群 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImGroupMapper extends BaseMapperX { -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java deleted file mode 100644 index 0f23180..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java +++ /dev/null @@ -1,81 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.group; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import org.apache.ibatis.annotations.Mapper; - -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.List; - -/** - * IM 群成员 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImGroupMemberMapper extends BaseMapperX { - - default List selectListByGroupId(Long groupId) { - return selectList(new LambdaQueryWrapperX().eq(ImGroupMemberDO::getGroupId, groupId)); - } - - default ImGroupMemberDO selectByGroupIdAndUserId(Long groupId, Long userId) { - return selectOne(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getGroupId, groupId) - .eq(ImGroupMemberDO::getUserId, userId)); - } - - default List selectListByGroupIdAndUserIds(Long groupId, Collection userIds) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getGroupId, groupId) - .in(ImGroupMemberDO::getUserId, userIds)); - } - - default List selectListByGroupIdAndStatus(Long groupId, Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getGroupId, groupId) - .eq(ImGroupMemberDO::getStatus, status)); - } - - default List selectListByUserIdAndStatus(Long userId, Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getUserId, userId) - .eq(ImGroupMemberDO::getStatus, status)); - } - - /** - * 查询用户已退群的成员记录 - *

- * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。 - * - * @param userId 用户编号 - * @param minQuitTime 最早退群时间(含),可空 - * @return 已退群成员记录列表 - */ - default List selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getUserId, userId) - .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus()) - .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime)); - } - - @SuppressWarnings("UnusedReturnValue") - default int updateByGroupIdAndStatus(Long groupId, Integer oldStatus, ImGroupMemberDO updateObj) { - return update(updateObj, new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getGroupId, groupId) - .eq(ImGroupMemberDO::getStatus, oldStatus)); - } - - @SuppressWarnings("UnusedReturnValue") - default int updateByGroupIdAndUserIdsAndStatus(Long groupId, Collection userIds, - Integer oldStatus, ImGroupMemberDO updateObj) { - return update(updateObj, new LambdaQueryWrapperX() - .eq(ImGroupMemberDO::getGroupId, groupId) - .in(ImGroupMemberDO::getUserId, userIds) - .eq(ImGroupMemberDO::getStatus, oldStatus)); - } - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java deleted file mode 100644 index b1f339f..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java +++ /dev/null @@ -1,117 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.message; - -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import org.apache.ibatis.annotations.Mapper; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * IM 群聊消息 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImGroupMessageMapper extends BaseMapperX { - - /** - * 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用) - * - * @param groupIds 用户当前仍在群内的群编号列表 - * @param minId 最小消息 id(不含) - * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 - * @param size 拉取数量 - * @return 消息列表(按 id 升序) - */ - default List selectListByMinId(List groupIds, Long minId, - LocalDateTime minSendTime, Integer size) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.in("group_id", groupIds) - .gt("id", minId) - .gt("send_time", minSendTime) - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) - .orderByAsc("id"); - wrapper.limitN(size); - return selectList(wrapper); - } - - /** - * 查询"退群前"的离线消息(退群成员使用) - *

- * 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、 - * 不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。 - * - * @param groupId 群编号 - * @param minId 最小消息 id(不含) - * @param minSendTime 最早发送时间(不含) - * @param quitTime 退群时间(含),仅返回退群当时已存在的消息 - * @param size 拉取数量 - * @return 消息列表(按 id 升序) - */ - default List selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId, - LocalDateTime minSendTime, - LocalDateTime quitTime, - Integer size) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.eq("group_id", groupId) - .gt("id", minId) - .gt("send_time", minSendTime) - .le("send_time", quitTime) - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) - .orderByAsc("id"); - wrapper.limitN(size); - return selectList(wrapper); - } - - /** - * 查询群聊历史消息(游标拉取) - * - * @param groupId 群编号 - * @param maxId 起始消息 id(不含),为空则从最新开始 - * @param limit 拉取数量 - * @param joinTime 入群时间,仅返回入群之后的消息 - * @return 消息列表(按 id 倒序) - */ - default List selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.eq("group_id", groupId) - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) - .lt(maxId != null, "id", maxId) - .ge(joinTime != null, "send_time", joinTime) - .orderByDesc("id"); - wrapper.limitN(limit); - return selectList(wrapper); - } - - default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { - return selectOne(new LambdaQueryWrapperX() - .eq(ImGroupMessageDO::getSenderId, senderId) - .eq(ImGroupMessageDO::getClientMessageId, clientMessageId)); - } - - /** - * 查询群内指定范围内待回执的消息 - *

- * 仅在用户"已读位置前进"时调用,避免全量扫描: - * 只有位于 (minId, maxId] 范围内、且仍处于 PENDING 的回执消息可能因本次已读而状态变化。 - * - * @param groupId 群编号 - * @param minId 起始消息 id(不含,上一次已读位置) - * @param maxId 结束消息 id(含,本次已读位置) - * @return 待回执消息列表 - */ - default List selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) { - return selectList(new LambdaQueryWrapperX() - .eq(ImGroupMessageDO::getGroupId, groupId) - .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .gt(minId != null, ImGroupMessageDO::getId, minId) - .le(ImGroupMessageDO::getId, maxId) - .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus())); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java deleted file mode 100644 index 46c8630..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java +++ /dev/null @@ -1,83 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.message; - -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import org.apache.ibatis.annotations.Mapper; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * IM 私聊消息 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImPrivateMessageMapper extends BaseMapperX { - - /** - * 根据 minId + 时间窗口增量拉取私聊消息 - * - * @param userId 当前用户编号 - * @param minId 最小消息 id(不含) - * @param minSendTime 最早发送时间(不含),限制离线消息时间窗口 - * @param size 拉取数量 - * @return 消息列表 - */ - default List selectListByMinId(Long userId, Long minId, - LocalDateTime minSendTime, Integer size) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.and(w -> w.eq("sender_id", userId) - .or() - .eq("receiver_id", userId)) - .gt("id", minId) - .gt("send_time", minSendTime) - .orderByAsc("id"); - wrapper.limitN(size); - return selectList(wrapper); - } - - /** - * 查询私聊历史消息(游标拉取) - * - * @param userId 当前用户编号 - * @param receiverId 对方用户编号 - * @param maxId 起始消息 id(不含),为空则从最新开始 - * @param limit 拉取数量 - * @return 消息列表(按 id 倒序) - */ - default List selectHistoryList(Long userId, Long receiverId, Long maxId, Integer limit) { - QueryWrapperX wrapper = new QueryWrapperX<>(); - wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId) - .or() - .eq("sender_id", receiverId).eq("receiver_id", userId)) - .ne("status", ImMessageStatusEnum.RECALL.getStatus()) - .lt(maxId != null, "id", maxId) - .orderByDesc("id"); - wrapper.limitN(limit); - return selectList(wrapper); - } - - default ImPrivateMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { - return selectOne(new LambdaQueryWrapperX() - .eq(ImPrivateMessageDO::getSenderId, senderId) - .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); - } - - default List selectListBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImPrivateMessageDO::getSenderId, senderId) - .eq(ImPrivateMessageDO::getReceiverId, receiverId) - .eq(ImPrivateMessageDO::getStatus, status)); - } - - default void updateByIdsAndStatus(List ids, Integer whereStatus, ImPrivateMessageDO updateObj) { - update(updateObj, new LambdaQueryWrapperX() - .in(ImPrivateMessageDO::getId, ids) - .eq(ImPrivateMessageDO::getStatus, whereStatus)); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java deleted file mode 100644 index 49b916d..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/sensitiveword/ImSensitiveWordMapper.java +++ /dev/null @@ -1,25 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.sensitiveword; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; -import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; -import org.apache.ibatis.annotations.Mapper; - -import java.util.List; - -/** - * IM 敏感词 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface ImSensitiveWordMapper extends BaseMapperX { - - // DONE @AI:已改名为 selectListByStatus,status 作为参数 - default List selectListByStatus(Integer status) { - return selectList(new LambdaQueryWrapperX() - .eq(ImSensitiveWordDO::getStatus, status)); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java deleted file mode 100644 index d2d7149..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java +++ /dev/null @@ -1,44 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.redis; - - -/** - * IM Redis Key 枚举类 - * - * @author 芋道源码 - */ -public interface RedisKeyConstants { - - /** - * 群消息已读位置 - * KEY 格式: im:group:message:read:{groupId} - * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId) - */ - String GROUP_MESSAGE_READ = "im:group:message:read:%s"; - - /** - * 好友关系是否存在的缓存 - *

- * KEY 格式:friend:{userId}_{friendUserId} - * VALUE 数据类型:Boolean - */ - String FRIEND = "friend"; - - /** - * 群信息缓存 - *

- * KEY 格式:group:{groupId} - * VALUE 数据类型:ImGroupDO - */ - String GROUP = "group"; - - /** - * 群有效成员 userId 列表缓存(仅 ENABLE 状态) - *

- * KEY 格式:group_member_ids:{groupId} - * VALUE 数据类型:List - *

- * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 - */ - String GROUP_MEMBER_IDS = "group_member_ids"; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java deleted file mode 100644 index ede9986..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java +++ /dev/null @@ -1,102 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.redis.message; - -import cn.hutool.core.convert.Convert; -import jakarta.annotation.Resource; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ; - -/** - * 群消息已读位置 Redis DAO - * - * @author 芋道源码 - */ -@Repository -public class GroupMessageReadRedisDAO { - - @Resource - private StringRedisTemplate stringRedisTemplate; - - /** - * 更新用户在某群的最大已读消息编号 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @param maxMessageId 最大已读消息编号 - */ - public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) { - String key = formatKey(groupId); - stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); - } - - /** - * 获取用户在某群的最大已读消息编号 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @return 最大已读消息编号,不存在则返回 null - */ - public Long getReadMaxMessageId(Long groupId, Long userId) { - String key = formatKey(groupId); - Object val = stringRedisTemplate.opsForHash().get(key, userId.toString()); - return Convert.toLong(val); - } - - /** - * 获取某群所有用户的已读位置 - * - * @param groupId 群编号 - * @return userId → maxReadMessageId 映射 - */ - public Map getReadMaxMessageIdMap(Long groupId) { - String key = formatKey(groupId); - Map entries = stringRedisTemplate.opsForHash().entries(key); - // 转换为 Long → Long 的 Map - Map result = new HashMap<>(entries.size()); - entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString()))); - return result; - } - - /** - * 删除用户在某群的已读位置 - * - * @param groupId 群编号 - * @param userId 用户编号 - */ - public void deleteReadMaxMessageId(Long groupId, Long userId) { - String key = formatKey(groupId); - stringRedisTemplate.opsForHash().delete(key, userId.toString()); - } - - /** - * 批量删除用户在某群的已读位置 - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { - String key = formatKey(groupId); - Object[] hashKeys = userIds.stream().map(String::valueOf).toArray(); - stringRedisTemplate.opsForHash().delete(key, hashKeys); - } - - /** - * 删除某群所有用户的已读位置(整个 Hash Key) - * - * @param groupId 群编号 - */ - public void deleteReadMaxMessageIdMap(Long groupId) { - String key = formatKey(groupId); - stringRedisTemplate.delete(key); - } - - private static String formatKey(Long groupId) { - return String.format(GROUP_MESSAGE_READ, groupId); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java deleted file mode 100644 index 61b6dae..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/package-info.java +++ /dev/null @@ -1,6 +0,0 @@ -/** - * 属于 erp 模块的 framework 封装 - * - * @author 芋道源码 - */ -package cn.iocoder.yudao.module.im.framework; diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java deleted file mode 100644 index 7e2b2ed..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/config/ImWebConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -package cn.iocoder.yudao.module.im.framework.web.config; - -import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; -import org.springdoc.core.models.GroupedOpenApi; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * im 模块的 web 组件的 Configuration - */ -@Configuration(proxyBeanMethods = false) -public class ImWebConfiguration { - - /** - * im 模块的 API 分组 - */ - @Bean - public GroupedOpenApi imGroupedOpenApi() { - return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("im"); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java deleted file mode 100644 index e000c8e..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/framework/web/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * trade 模块的 web 配置 - */ -package cn.iocoder.yudao.module.im.framework.web; diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java deleted file mode 100644 index 9dce3bd..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java +++ /dev/null @@ -1,88 +0,0 @@ -package cn.iocoder.yudao.module.im.service.friend; - -import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; - -import java.util.Collection; -import java.util.List; - -/** - * IM 好友关系 Service 接口 - * - * @author 芋道源码 - */ -public interface ImFriendService { - - /** - * 判断两个用户是否是好友关系(未删除状态) - * - * @param userId 用户编号 - * @param friendUserId 好友用户编号 - * @return 是否好友 - */ - boolean isFriend(Long userId, Long friendUserId); - - /** - * 获得当前用户的好友列表(含已删除状态) - *

- * 返回的列表里**包含已删除状态**,前端按 status 区分展示(已删除的置灰); - * 原因是本地已经建立会话的好友被单向删除后,前端仍需要展示头像/昵称。 - * - * @param userId 用户编号 - * @return 好友关系列表(含已删除的记录) - */ - List getFriendList(Long userId); - - /** - * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态) - * - * @param userId 用户编号 - * @param friendUserIds 需要校验的好友用户编号集合 - * @return 有效好友关系列表 - */ - List getActiveFriendList(Long userId, Collection friendUserIds); - - /** - * 查询一个好友关系记录(仅限本人视角) - * - * @param userId 当前登录用户编号 - * @param friendUserId 目标用户编号 - * @return 好友关系记录,不存在返回 null - */ - ImFriendDO getFriend(Long userId, Long friendUserId); - - /** - * 添加好友(双向绑定) - *

- * 核心流程: - * 1. 校验:不允许添加自己;对方必须存在 - * 2. 双向插入/更新关系(若已存在则刷新为未删除) - * 3. 推送"你们已成为好友"系统提示(TODO) - * - * @param userId 当前登录用户编号 - * @param friendUserId 好友的用户编号 - */ - void addFriend(Long userId, Long friendUserId); - - /** - * 删除好友(双向软删除) - *

- * 两边关系都标记为已删除状态。 - * - * @param userId 当前登录用户编号 - * @param friendUserId 好友的用户编号 - */ - void deleteFriend(Long userId, Long friendUserId); - - /** - * 更新好友信息(仅更新本人→对方的单边关系属性) - *

- * 目前支持:免打扰(muted);后续可扩展备注等字段。 - * - * @param userId 当前登录用户编号 - * @param reqVO 更新请求 - */ - void updateFriend(Long userId, ImFriendUpdateReqVO reqVO); - - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java deleted file mode 100644 index 86b6a89..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java +++ /dev/null @@ -1,183 +0,0 @@ -package cn.iocoder.yudao.module.im.service.friend; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; -import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.annotation.Lazy; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; - -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.List; -import java.util.Objects; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE; - -/** - * IM 好友关系 Service 实现类 - * - * @author 芋道源码 - */ -@Slf4j -@Service -@Validated -public class ImFriendServiceImpl implements ImFriendService { - - @Resource - private ImFriendMapper imFriendMapper; - - @Resource - private AdminUserApi adminUserApi; - - @Resource - private ImWebSocketService imWebSocketService; - @Resource - @Lazy - private ImPrivateMessageService privateMessageService; - - @Override - @Cacheable(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId", unless = "#result == null") - public boolean isFriend(Long userId, Long friendUserId) { - ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); - return friend != null && CommonStatusEnum.isEnable(friend.getStatus()); - } - - @Override - public List getFriendList(Long userId) { - return imFriendMapper.selectListByUserId(userId); - } - - @Override - public List getActiveFriendList(Long userId, Collection friendUserIds) { - return imFriendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds, - CommonStatusEnum.ENABLE.getStatus()); - } - - @Override - public ImFriendDO getFriend(Long userId, Long friendUserId) { - return imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); - } - - @Override - public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) { - // 1. 校验好友关系存在 - ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId()); - if (friend == null) { - throw exception(FRIEND_NOT_FRIEND); - } - - // 2. 更新好友属性(目前仅免打扰) - imFriendMapper.updateById(new ImFriendDO().setId(friend.getId()).setMuted(reqVO.getMuted())); - - // 3. 推送好友更新通知(多端同步) - imWebSocketService.sendPrivateMessageAsync(userId, - ImPrivateMessageDTO.ofFriendUpdate(userId, reqVO.getFriendUserId())); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void addFriend(Long userId, Long friendUserId) { - // 1.1 校验:不允许添加自己为好友 - if (Objects.equals(userId, friendUserId)) { - throw exception(FRIEND_ADD_SELF); - } - // 1.2 校验对方存在(禁用/不存在的账号不允许添加) - adminUserApi.validateUser(friendUserId); - - // 2. 双向绑定 - getSelf().addFriend0(userId, friendUserId); - getSelf().addFriend0(friendUserId, userId); - - // 3.1 插入 TIP_TEXT 系统提示消息并推送双方 - privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_ADD_TIP_MESSAGE); - // 3.2 推送好友添加通知给双方 - imWebSocketService.sendPrivateMessageAsync(userId, - ImPrivateMessageDTO.ofFriendAdd(friendUserId, userId)); - imWebSocketService.sendPrivateMessageAsync(friendUserId, - ImPrivateMessageDTO.ofFriendAdd(userId, friendUserId)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void deleteFriend(Long userId, Long friendUserId) { - // 1. 双向标记为 DISABLE + 记录 deleteTime - getSelf().deleteFriend0(userId, friendUserId); - getSelf().deleteFriend0(friendUserId, userId); - - // 2.1 插入 TIP_TEXT 系统提示消息并推送双方 - privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE); - // 2.2 推送好友删除通知给双方 - imWebSocketService.sendPrivateMessageAsync(userId, - ImPrivateMessageDTO.ofFriendDelete(userId, friendUserId)); - imWebSocketService.sendPrivateMessageAsync(friendUserId, - ImPrivateMessageDTO.ofFriendDelete(friendUserId, userId)); - } - - /** - * 单向绑定好友关系: - * - 首次:新增记录(status=ENABLE,addTime=now) - * - 已存在且 ENABLE:无需重复操作 - * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime) - *

- * 并发安全:依靠 im_friend 表的唯一索引 uk_im_friend_user_friend(user_id, friend_user_id) 保证幂等, - * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 - */ - @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId") - public void addFriend0(Long userId, Long friendUserId) { - ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); - // 情况一:已存在记录 → 恢复或跳过 - if (exists != null) { - if (CommonStatusEnum.isEnable(exists.getStatus())) { - return; - } - imFriendMapper.updateById(new ImFriendDO().setId(exists.getId()) - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now())); - return; - } - // 情况二:新增好友关系 - ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) - .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build(); - try { - imFriendMapper.insert(friend); - } catch (DuplicateKeyException e) { - // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),直接忽略 - log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,忽略]", userId, friendUserId); - } - } - - /** - * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime) - */ - @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId") - public void deleteFriend0(Long userId, Long friendUserId) { - ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); - if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) { - return; - } - imFriendMapper.updateById(new ImFriendDO().setId(exists.getId()) - .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now())); - } - - private ImFriendServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java deleted file mode 100644 index fbd4bb7..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java +++ /dev/null @@ -1,140 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import jakarta.validation.Valid; - -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.List; - -/** - * 群成员 Service 接口 - * - * @author 芋道源码 - */ -public interface ImGroupMemberService { - - /** - * 获得群成员 - * - * @param id 编号 - * @return 群成员 - */ - ImGroupMemberDO getGroupMember(Long id); - - /** - * 根据群组 id 查询群成员(包含所有状态) - * - * @param groupId 群组id - * @return 群成员列表 - */ - List getGroupMemberListByGroupId(Long groupId); - - /** - * 根据群编号查询有效成员列表(仅 ENABLE 状态) - * - * @param groupId 群编号 - * @return 有效群成员列表 - */ - List getActiveGroupMemberListByGroupId(Long groupId); - - /** - * 根据群编号查询有效成员的 userId 列表(仅 ENABLE 状态) - *

- * 相比 {@link #getActiveGroupMemberListByGroupId(Long)},只返回 userId,结果体积小,并带有 Redis 缓存。 - * 适用于"群消息推送目标"等只需要 userId 的场景; - * 需要 joinTime/quitTime/status 等字段做历史消息可见性判断时,仍应使用完整列表方法。 - * - * @param groupId 群编号 - * @return 有效群成员 userId 列表 - */ - List getActiveGroupMemberUserIdsByGroupId(Long groupId); - - /** - * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) - * - * @param userId 用户编号 - * @return 有效群成员记录列表 - */ - List getActiveGroupMemberListByUserId(Long userId); - - /** - * 查询用户已退群的群成员记录(DISABLE 状态) - * - * @param userId 用户编号,必传 - * @param minQuitTime 最早退群时间(含),可空 - * @return 已退群成员记录列表 - */ - List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime); - - /** - * 添加群成员(入群) - *

- * 插入一条 ENABLE 状态的群成员记录,并设置 joinTime。 - * 可用于创建群时添加群主、邀请入群等场景。 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @return 群成员记录 - */ - @SuppressWarnings("UnusedReturnValue") - ImGroupMemberDO addGroupMember(Long groupId, Long userId); - - /** - * 批量添加群成员(入群) - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - void addGroupMembers(Long groupId, Collection userIds); - - /** - * 校验用户是否为群的有效成员 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @return 群成员记录 - */ - ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId); - - /** - * 更新群成员信息(群内昵称、群名备注、免打扰等) - *

- * 内部会校验用户是否为群的有效成员 - * - * @param userId 当前登录用户编号 - * @param updateReqVO 更新信息 - */ - void updateGroupMember(Long userId, @Valid ImGroupMemberUpdateReqVO updateReqVO); - - /** - * 移除指定群成员(设置为 DISABLE 状态) - *

- * 用于退群、踢出场景 - * - * @param groupId 群编号 - * @param userId 用户编号 - */ - void removeGroupMember(Long groupId, Long userId); - - /** - * 批量移除指定群成员(设置为 DISABLE 状态) - *

- * 用于批量踢出场景 - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - void removeGroupMembers(Long groupId, Collection userIds); - - /** - * 移除群的全部成员(设置为 DISABLE 状态) - *

- * 用于群解散场景 - * - * @param groupId 群编号 - */ - void removeGroupMembersByGroupId(Long groupId); - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java deleted file mode 100644 index 0b92f0d..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java +++ /dev/null @@ -1,212 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.hutool.core.collection.CollUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Service; -import org.springframework.validation.annotation.Validated; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; - -/** - * 群成员 Service 实现类 - * - * @author 芋道源码 - */ -@Slf4j -@Service -@Validated -public class ImGroupMemberServiceImpl implements ImGroupMemberService { - - @Resource - private ImGroupMemberMapper groupMemberMapper; - - @Resource - private ImWebSocketService webSocketService; - - @Override - public ImGroupMemberDO getGroupMember(Long id) { - return groupMemberMapper.selectById(id); - } - - @Override - public List getGroupMemberListByGroupId(Long groupId) { - return groupMemberMapper.selectListByGroupId(groupId); - } - - @Override - public List getActiveGroupMemberListByGroupId(Long groupId) { - return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); - } - - /** - * 只缓存 userId 列表而非整个 {@link ImGroupMemberDO},理由: - *

    - *
  • 体积小:500 人群约 4KB,失效/序列化成本低;
  • - *
  • 失效面窄:仅 {@link #addGroupMember}/{@link #addGroupMembers}/ - * {@link #removeGroupMember}/{@link #removeGroupMembers}/{@link #removeGroupMembersByGroupId} - * 这类影响成员集合的写操作需要失效; - * {@link #updateGroupMember}(昵称/备注/免打扰)不修改集合成员,不需要失效。
  • - *
- */ - @Override - @Cacheable(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public List getActiveGroupMemberUserIdsByGroupId(Long groupId) { - List members = groupMemberMapper.selectListByGroupIdAndStatus( - groupId, CommonStatusEnum.ENABLE.getStatus()); - return convertList(members, ImGroupMemberDO::getUserId); - } - - @Override - public List getActiveGroupMemberListByUserId(Long userId) { - return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); - } - - @Override - public List getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) { - return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); - } - - /** - * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等, - * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 - */ - @Override - @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { - LocalDateTime now = LocalDateTime.now(); - // 情况一:已存在记录 → 复活或跳过 - ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); - if (exists != null) { - if (CommonStatusEnum.isDisable(exists.getStatus())) { - groupMemberMapper.updateById(new ImGroupMemberDO().setId(exists.getId()) - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); - } - return groupMemberMapper.selectById(exists.getId()); - } - // 情况二:新增成员 - ImGroupMemberDO member = new ImGroupMemberDO() - .setGroupId(groupId).setUserId(userId) - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now); - try { - groupMemberMapper.insert(member); - return member; - } catch (DuplicateKeyException e) { - // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),查询返回 - log.warn("[addGroupMember][groupId({}) userId({}) 并发插入冲突,查询返回]", groupId, userId); - return groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); - } - } - - @Override - @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public void addGroupMembers(Long groupId, Collection userIds) { - LocalDateTime now = LocalDateTime.now(); - // 1.1 查询已有记录(含已退群的 DISABLE 记录) - List existMembers = groupMemberMapper.selectListByGroupIdAndUserIds(groupId, userIds); - Map existMap = convertMap(existMembers, ImGroupMemberDO::getUserId); - // 1.2 分类:已有记录 → UPDATE,新成员 → INSERT - List inserts = new ArrayList<>(); - List updates = new ArrayList<>(); - for (Long userId : userIds) { - ImGroupMemberDO exist = existMap.get(userId); - if (exist == null) { - inserts.add(new ImGroupMemberDO().setGroupId(groupId).setUserId(userId) - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); - } else if (CommonStatusEnum.DISABLE.getStatus().equals(exist.getStatus())) { - updates.add(new ImGroupMemberDO().setId(exist.getId()) - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)); - } - } - - // 2.1 先做 update,update 没有并发冲突风险 - if (CollUtil.isNotEmpty(updates)) { - groupMemberMapper.updateBatch(updates); - } - // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId), - // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。 - if (CollUtil.isNotEmpty(inserts)) { - try { - groupMemberMapper.insertBatch(inserts); - } catch (DuplicateKeyException e) { - log.warn("[addGroupMembers][groupId({}) userIds({}) 批量插入冲突,降级为逐个处理]", groupId, userIds); - for (ImGroupMemberDO insert : inserts) { - addGroupMember(groupId, insert.getUserId()); - } - } - } - } - - @Override - public ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId) { - ImGroupMemberDO member = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); - if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { - throw exception(GROUP_MEMBER_NOT_IN_GROUP); - } - return member; - } - - @Override - public void updateGroupMember(Long userId, ImGroupMemberUpdateReqVO updateReqVO) { - // 1. 校验是群的有效成员 - ImGroupMemberDO member = validateMemberInGroup(updateReqVO.getGroupId(), userId); - - // 2. 更新群成员信息 - ImGroupMemberDO updateObj = BeanUtils.toBean(updateReqVO, ImGroupMemberDO.class) - .setId(member.getId()); - groupMemberMapper.updateById(updateObj); - - // 3. 推送群成员变更通知(多端同步,仅推给自己) - webSocketService.sendGroupMessageAsync(userId, - ImGroupMessageDTO.ofGroupMemberUpdate(userId, updateReqVO.getGroupId())); - } - - @Override - @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public void removeGroupMember(Long groupId, Long userId) { - // 1. 校验是群的有效成员 - ImGroupMemberDO member = validateMemberInGroup(groupId, userId); - // 2. 更新为退群状态 - groupMemberMapper.updateById(new ImGroupMemberDO().setId(member.getId()) - .setStatus(CommonStatusEnum.DISABLE.getStatus()) - .setQuitTime(LocalDateTime.now())); - } - - @Override - @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public void removeGroupMembers(Long groupId, Collection userIds) { - groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, - CommonStatusEnum.ENABLE.getStatus(), - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) - .setQuitTime(LocalDateTime.now())); - } - - @Override - @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") - public void removeGroupMembersByGroupId(Long groupId) { - groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) - .setQuitTime(LocalDateTime.now())); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java deleted file mode 100644 index b38991e..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java +++ /dev/null @@ -1,125 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import jakarta.validation.Valid; - -import java.util.List; - -/** - * 群 Service 接口 - * - * @author 芋道源码 - */ -public interface ImGroupService { - - // ==================== 群的写操作 ==================== - - /** - * 创建群 - *

- * 同时将当前登录用户设置为群主,并插入群主的群成员记录 - * - * @param createReqVO 创建信息 - * @param userId 当前登录用户编号(群主) - * @return 创建后的群信息 - */ - ImGroupDO createGroup(@Valid ImGroupCreateReqVO createReqVO, Long userId); - - /** - * 更新群信息 - * - * @param updateReqVO 更新信息 - * @param userId 当前登录用户编号 - * @return 更新后的群信息 - */ - ImGroupDO updateGroup(@Valid ImGroupUpdateReqVO updateReqVO, Long userId); - - /** - * 解散群 - *

- * 仅群主可执行 - * - * @param id 群编号 - * @param userId 当前登录用户编号 - */ - void dissolveGroup(Long id, Long userId); - - // ==================== 群的读操作 ==================== - - /** - * 获得群 - * - * @param id 编号 - * @return 群 - */ - ImGroupDO getGroup(Long id); - - /** - * 校验群存在且未封禁、未解散 - * - * @param groupId 群编号 - * @return 群信息 - */ - ImGroupDO validateGroupExists(Long groupId); - - /** - * 校验当前用户是否为群主 - * - * @param groupId 群编号 - * @param userId 用户编号 - * @return 群信息 - */ - @SuppressWarnings("UnusedReturnValue") - ImGroupDO validateGroupOwner(Long groupId, Long userId); - - /** - * 获取指定用户的群列表 - *

- * 返回用户当前仍有效的群,以及最近 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#MESSAGE_GROUP_PULL_MAX_DAYS} - * 天内退群的群 —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 - * - * @param userId 用户编号 - * @return 群列表 - */ - List getMyGroupList(Long userId); - - // ==================== 群成员的写操作 ==================== - // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, - // 保持 ImGroupMemberService 无 WebSocket 推送等外部依赖,职责更单一。 - - /** - * 邀请用户加入群 - *

- * 群成员即可执行,支持批量。 - * 校验群人数上限,邀请后推送提示消息和群创建事件给被邀请人。 - * - * @param userId 当前登录用户编号 - * @param inviteReqVO 邀请信息 - */ - void inviteGroupMember(Long userId, @Valid ImGroupMemberInviteReqVO inviteReqVO); - - /** - * 退群 - *

- * 群主不可退群(只能解散) - * - * @param groupId 群编号 - * @param userId 当前登录用户编号 - */ - void quitGroup(Long groupId, Long userId); - - /** - * 移除群成员(踢人) - *

- * 仅群主可执行,且不能移除自己,支持批量 - * - * @param userId 当前登录用户编号(群主) - * @param removeReqVO 移除信息 - */ - void removeGroupMember(Long userId, @Valid ImGroupMemberRemoveReqVO removeReqVO); - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java deleted file mode 100644 index 3801f4b..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ /dev/null @@ -1,291 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.hutool.core.bean.BeanUtil; -import cn.hutool.core.bean.copier.CopyOptions; -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.ObjUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; -import jakarta.annotation.Resource; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; - -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; - -// TODO @芋艿:群主的调整,暂时不考虑。后续在弄; -/** - * 群 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -public class ImGroupServiceImpl implements ImGroupService { - - @Resource - private ImGroupMapper groupMapper; - - @Resource - @Lazy // 避免循环依赖 - private ImGroupMemberService groupMemberService; - @Resource - @Lazy // 避免循环依赖 - private ImGroupMessageService groupMessageService; - @Resource - private ImWebSocketService webSocketService; - - @Resource - private ImFriendService friendService; - - @Resource - private AdminUserApi adminUserApi; - - // ==================== 群的写操作 ==================== - - @Override - @Transactional(rollbackFor = Exception.class) - public ImGroupDO createGroup(ImGroupCreateReqVO createReqVO, Long userId) { - // 1. 插入群记录 - ImGroupDO group = BeanUtils.toBean(createReqVO, ImGroupDO.class) - .setOwnerUserId(userId).setStatus(CommonStatusEnum.ENABLE.getStatus()); - groupMapper.insert(group); - - // 2. 将群主插入为群成员 - groupMemberService.addGroupMember(group.getId(), userId); - - // 3. 推送群创建事件给群主(多端同步) - webSocketService.sendGroupMessageAsync(userId, - ImGroupMessageDTO.ofGroupCreate(userId, group.getId())); - return group; - } - - @Override - @CacheEvict(cacheNames = GROUP, key = "#updateReqVO.id") - @Transactional(rollbackFor = Exception.class) - public ImGroupDO updateGroup(ImGroupUpdateReqVO updateReqVO, Long userId) { - // 1.1 校验群存在 - ImGroupDO group = validateGroupExists(updateReqVO.getId()); - // 1.2 校验操作人是群主 - if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { - throw exception(GROUP_NOT_OWNER); - } - - // 2. 更新数据库 - ImGroupDO updateObj = BeanUtils.toBean(updateReqVO, ImGroupDO.class); - groupMapper.updateById(updateObj); - BeanUtil.copyProperties(updateReqVO, group, CopyOptions.create().ignoreNullValue()); - - // 3. 群信息的变更,推送给所有群成员 - List members = groupMemberService.getActiveGroupMemberListByGroupId(group.getId()); - Set memberUserIds = convertSet(members, ImGroupMemberDO::getUserId); - webSocketService.sendGroupMessageAsync(memberUserIds, - ImGroupMessageDTO.ofGroupUpdate(userId, group.getId())); - return group; - } - - @Override - @CacheEvict(cacheNames = GROUP, key = "#id") - @Transactional(rollbackFor = Exception.class) - public void dissolveGroup(Long id, Long userId) { - // 1. 校验群存在 + 当前用户是群主 - validateGroupOwner(id, userId); - - // 2.1 获取群成员列表(在移除之前,后续推送需要) - List members = groupMemberService.getActiveGroupMemberListByGroupId(id); - Set memberUserIds = convertSet(members, ImGroupMemberDO::getUserId); - // 2.2 更新群状态为已解散 - groupMapper.updateById(new ImGroupDO().setId(id) - .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); - // 2.3 移除全部群成员 - groupMemberService.removeGroupMembersByGroupId(id); - // 2.4 清理已读缓存 - groupMessageService.deleteReadMaxMessageIdMap(id); - - // 3.1 发送解散提示消息(TIP_TEXT)并推送 - AdminUserRespDTO user = adminUserApi.getUser(userId); - String tipContent = StrUtil.format(GROUP_DISSOLVE_TIP_MESSAGE, user.getNickname()); - groupMessageService.sendTipGroupMessage(userId, id, memberUserIds, tipContent); - // 3.2 推送群删除事件给所有原群成员 - webSocketService.sendGroupMessageAsync(memberUserIds, - ImGroupMessageDTO.ofGroupDelete(userId, id)); - } - - // ==================== 群成员的写操作 ==================== - - @Override - public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { - Long groupId = inviteReqVO.getGroupId(); - // 1.1 校验群存在 + 当前用户是群成员 - validateGroupExists(groupId); - groupMemberService.validateMemberInGroup(groupId, userId); - // 1.2 排除已在群中的用户 - List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); - activeMembers.forEach(member -> inviteReqVO.getMemberUserIds().remove(member.getUserId())); - if (CollUtil.isEmpty(inviteReqVO.getMemberUserIds())) { - return; - } - // 1.3 校验被邀请人都是当前用户的好友 - List memberUserIds = inviteReqVO.getMemberUserIds(); - List friends = friendService.getActiveFriendList(userId, memberUserIds); - Set friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId); - Collection notFriendUserIds = CollUtil.subtract(memberUserIds, friendUserIds); - if (CollUtil.isNotEmpty(notFriendUserIds)) { - throw exception(GROUP_INVITE_NOT_FRIEND, getUserNicknames(notFriendUserIds)); - } - // 1.4 校验群人数上限 - if (activeMembers.size() + memberUserIds.size() > MAX_GROUP_MEMBER) { - throw exception(GROUP_MEMBER_EXCEED, MAX_GROUP_MEMBER); - } - - // 2. 批量添加群成员 - groupMemberService.addGroupMembers(groupId, memberUserIds); - - // 3.1 推送群创建事件给被邀请人(让客户端新增群会话) - webSocketService.sendGroupMessageAsync(new HashSet<>(memberUserIds), - ImGroupMessageDTO.ofGroupCreate(userId, groupId)); - // 3.2 发送邀请提示消息给所有群成员 - String tipContent = StrUtil.format(GROUP_INVITE_TIP_MESSAGE, - getUserNicknames(List.of(userId)), getUserNicknames(memberUserIds)); - List allMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); - Set allMemberUserIds = convertSet(allMembers, ImGroupMemberDO::getUserId); - groupMessageService.sendTipGroupMessage(userId, groupId, allMemberUserIds, tipContent); - } - - @Override - public void quitGroup(Long groupId, Long userId) { - // 1. 校验群存在 - ImGroupDO group = validateGroupExists(groupId); - // 2. 群主不可退群 - if (ObjUtil.equal(group.getOwnerUserId(), userId)) { - throw exception(GROUP_OWNER_CANNOT_QUIT); - } - - // 3.1 移除群成员 - groupMemberService.removeGroupMember(groupId, userId); - // 3.2 清理已读缓存 - groupMessageService.deleteReadMaxMessageId(groupId, userId); - - // 4.1 发送退群提示消息(TIP_TEXT) - groupMessageService.sendTipGroupMessage(userId, groupId, Set.of(userId), GROUP_QUIT_TIP_MESSAGE); - // 4.2 推送群删除事件 - webSocketService.sendGroupMessageAsync(Set.of(userId), - ImGroupMessageDTO.ofGroupDelete(userId, groupId)); - } - - @Override - public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { - Long groupId = removeReqVO.getGroupId(); - // 1. 校验群存在 + 当前用户是群主 - validateGroupOwner(groupId, userId); - // 2. 不能移除自己 - if (removeReqVO.getMemberUserIds().contains(userId)) { - throw exception(GROUP_CANNOT_REMOVE_SELF); - } - - // 3.1 批量移除群成员 - Set memberUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); - groupMemberService.removeGroupMembers(groupId, memberUserIds); - // 3.2 批量清理已读缓存 - groupMessageService.deleteReadMaxMessageIds(groupId, memberUserIds); - - // 4.1 发送踢出提示消息(TIP_TEXT) - groupMessageService.sendTipGroupMessage(userId, groupId, memberUserIds, GROUP_REMOVE_TIP_MESSAGE); - // 4.2 推送群删除事件 - webSocketService.sendGroupMessageAsync(memberUserIds, - ImGroupMessageDTO.ofGroupDelete(userId, groupId)); - } - - // ==================== 群的读操作 ==================== - - @Override - public ImGroupDO validateGroupExists(Long id) { - ImGroupDO group = getSelf().getGroup(id); - if (group == null) { - throw exception(GROUP_NOT_EXISTS); - } - if (Boolean.TRUE.equals(group.getBanned())) { - throw exception(GROUP_BANNED); - } - if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { - throw exception(GROUP_DISSOLVED); - } - return group; - } - - @Override - public ImGroupDO validateGroupOwner(Long groupId, Long userId) { - ImGroupDO group = validateGroupExists(groupId); - if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { - throw exception(GROUP_NOT_OWNER); - } - return group; - } - - @Override - @Cacheable(cacheNames = GROUP, key = "#id", unless = "#result == null") - public ImGroupDO getGroup(Long id) { - return groupMapper.selectById(id); - } - - @Override - public List getMyGroupList(Long userId) { - // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) - List members = groupMemberService.getActiveGroupMemberListByUserId(userId); - // 1.2 再查最近 MESSAGE_GROUP_PULL_MAX_DAYS 天内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) - LocalDateTime minQuitTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); - members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); - if (CollUtil.isEmpty(members)) { - return Collections.emptyList(); - } - - // 2. 批量查询群信息(不按 status / banned 过滤,已解散 / 封禁的群也要返回,供前端展示历史消息的群名 / 头像) - Set groupIds = convertSet(members, ImGroupMemberDO::getGroupId); - return groupMapper.selectByIds(groupIds); - } - - private ImGroupServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); - } - - /** - * 根据用户编号集合,拼接用户昵称字符串(逗号分隔) - * - * @param userIds 用户编号集合 - * @return 昵称字符串,如 "张三,李四" - */ - private String getUserNicknames(Collection userIds) { - Map userMap = adminUserApi.getUserMap(userIds); - return userIds.stream() - .map(id -> userMap.containsKey(id) ? userMap.get(id).getNickname() : String.valueOf(id)) - .collect(Collectors.joining(",")); - } - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java deleted file mode 100644 index bfd279b..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java +++ /dev/null @@ -1,116 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; - -import java.util.Collection; -import java.util.List; -import java.util.Set; - -/** - * IM 群聊消息 Service 接口 - * - * @author 芋道源码 - */ -public interface ImGroupMessageService { - - /** - * 发送群聊消息 - * - * @param senderId 发送人编号 - * @param reqVO 发送请求 - * @return 消息 - */ - ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); - - /** - * 拉取群聊消息(增量) - * - * @param userId 当前用户编号 - * @param minId 最小消息 id(不含) - * @param size 拉取数量 - * @return 消息列表 - */ - List pullGroupMessageList(Long userId, Long minId, Integer size); - - /** - * 标记群聊消息已读 - * - * @param userId 当前用户编号 - * @param groupId 群编号 - * @param messageId 已读到的消息编号 - */ - void readGroupMessages(Long userId, Long groupId, Long messageId); - - /** - * 撤回群聊消息 - * - * @param userId 当前用户编号 - * @param messageId 消息编号 - * @return 撤回后的提示消息 - */ - ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); - - /** - * 获取群消息的已读用户列表 - * - * @param userId 当前用户编号 - * @param groupId 群编号 - * @param messageId 消息编号 - * @return 已读用户编号列表 - */ - List getGroupReadUsers(Long userId, Long groupId, Long messageId); - - /** - * 查询群聊历史消息(游标拉取) - * - * @param userId 当前用户编号 - * @param reqVO 拉取请求 - * @return 消息列表(按 id 倒序) - */ - List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); - - /** - * 清理用户在某群的已读位置缓存 - *

- * 用于成员退群场景 - * - * @param groupId 群编号 - * @param userId 用户编号 - */ - void deleteReadMaxMessageId(Long groupId, Long userId); - - /** - * 批量清理用户在某群的已读位置缓存 - *

- * 用于批量踢出场景 - * - * @param groupId 群编号 - * @param userIds 用户编号集合 - */ - void deleteReadMaxMessageIds(Long groupId, Collection userIds); - - /** - * 清理某群所有用户的已读位置缓存 - *

- * 用于群解散场景 - * - * @param groupId 群编号 - */ - void deleteReadMaxMessageIdMap(Long groupId); - - /** - * 发送群聊系统提示消息(TIP_TEXT) - *

- * 插入一条 TIP_TEXT 消息到群聊记录,并推送给指定用户 - * - * @param senderId 发送人编号(操作者) - * @param groupId 群编号 - * @param memberUserIds 推送目标用户编号集合 - * @param content 提示内容 - */ - void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content); - -} - diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java deleted file mode 100644 index 46b6d46..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ /dev/null @@ -1,549 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.lang.Assert; -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.ObjUtil; -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; -import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; -import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; -import cn.iocoder.yudao.module.im.service.group.ImGroupService; -import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; - -import java.time.LocalDateTime; -import java.util.*; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; - -/** - * IM 群聊消息 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class ImGroupMessageServiceImpl implements ImGroupMessageService { - - /** - * 仅用于规避群消息 pull 的"假空页": - * 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。 - * 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。 - */ - private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; - - @Resource - private ImGroupMessageMapper groupMessageMapper; - @Resource - private GroupMessageReadRedisDAO groupMessageReadRedisDAO; - - @Resource - private ImGroupService groupService; - @Resource - private ImGroupMemberService groupMemberService; - @Resource - private ImSensitiveWordService sensitiveWordService; - - @Resource - private ImWebSocketService imWebSocketService; - - @Override - public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO) { - // 1.1 幂等校验:根据 senderId + clientMessageId 查重 - ImGroupMessageDO existing = groupMessageMapper.selectBySenderIdAndClientMessageId( - senderId, reqVO.getClientMessageId()); - if (existing != null) { - log.info("[sendGroupMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", - senderId, reqVO.getClientMessageId(), existing.getId()); - return existing; - } - // 1.2 校验群存在、发送人仍在群中 - groupService.validateGroupExists(reqVO.getGroupId()); - groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId); - // 1.3 文本消息敏感词过滤 - if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { - sensitiveWordService.validateText(reqVO.getContent()); - } - - // 2. 构建并保存消息 - ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt()) - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); - groupMessageMapper.insert(message); - - // 3. WebSocket 推送给群成员(异步) - sendGroupMessageEvent(message); - return message; - } - - @Override - public List pullGroupMessageList(Long userId, Long minId, Integer size) { - if (size > MESSAGE_MAX_PULL_SIZE) { - throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); - } - - // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 - LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); - - // 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试 - List activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); - Map memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); - List messages = new ArrayList<>(); - if (CollUtil.isNotEmpty(activeMembers)) { - List groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); - messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap)); - } - // 1.2 补齐"退群前"的消息: - // - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进; - // - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。 - messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap)); - - // 2. 合并后统一过滤,得到当前用户可见的结果 - List result = filterGroupMessageList(messages, memberMap, userId, size); - - // 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 - appendMessageStatusAndReceipt(userId, result); - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); - return result; - } - - /** - * 拉取当前仍在群的主路径消息。 - * - * 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测, - * 直到命中可见消息或确认该来源已耗尽。 - */ - private List pullActiveGroupMessageList(Long userId, List groupIds, Long minId, - Integer size, LocalDateTime minSendTime, - Map memberMap) { - // 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界 - Long activeMinId = minId; - for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { - // 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束 - List messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId, - minSendTime, size); - if (CollUtil.isEmpty(messages)) { - return Collections.emptyList(); - } - boolean hasVisibleMessage = CollUtil.anyMatch(messages, - message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId)); - boolean sourceExhausted = messages.size() < size; - // 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息 - if (hasVisibleMessage || sourceExhausted) { - return messages; - } - - // 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止 - Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); - if (maxMessageId == null || maxMessageId <= activeMinId) { - return Collections.emptyList(); - } - activeMinId = maxMessageId; - } - return Collections.emptyList(); - } - - /** - * 拉取已离开群路径的消息(退群前消息) - */ - private List pullQuitGroupMessageList(Long userId, Long minId, Integer size, - LocalDateTime minSendTime, - Map memberMap) { - // 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤 - LocalDateTime minQuitTime = minSendTime; - if (minId != null && minId > 0) { - ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId); - if (minMessage != null && minMessage.getSendTime() != null - && minMessage.getSendTime().isAfter(minSendTime)) { - minQuitTime = minMessage.getSendTime(); - } - } - // 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限 - List messages = new ArrayList<>(); - List quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime); - for (ImGroupMemberDO quitMember : quitMembers) { - // 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用 - List quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); - if (CollUtil.isEmpty(quitGroupMessages)) { - continue; - } - messages.addAll(quitGroupMessages); - memberMap.put(quitMember.getGroupId(), quitMember); - } - return messages; - } - - /** - * 过滤一批原始群消息,得到当前用户可见的返回结果 - */ - private List filterGroupMessageList(List messages, - Map memberMap, - Long userId, Integer size) { - // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, - // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 - return messages.stream() - .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) - .sorted(Comparator.comparing(ImGroupMessageDO::getId)) - .limit(size) - .toList(); - } - - /** - * 补全消息已读态和回执已读人数 - * - * 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD - * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 - */ - @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) - private void appendMessageStatusAndReceipt(Long userId, List messages) { - if (CollUtil.isEmpty(messages)) { - return; - } - Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 - Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) - Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 - for (ImGroupMessageDO message : messages) { - // 消息已读态(status):撤回 > 已读 > 未读 - Long groupId = message.getGroupId(); - long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { - Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId); - return readMaxMsgId != null ? readMaxMsgId : -1L; - }); - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - // 保持撤回态 - } else if (readMaxMessageId >= message.getId()) { - message.setStatus(ImMessageStatusEnum.READ.getStatus()); - } else { - message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); - } - - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) - if (ObjUtil.notEqual(message.getSenderId(), userId) - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { - continue; - } - Map positions = readPositionsByGroup.computeIfAbsent(groupId, - gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid)); - List allMembers = membersByGroup.computeIfAbsent(groupId, - groupMemberService::getGroupMemberListByGroupId); - Set visibleUserIds = getVisibleUserIds(message, allMembers); - visibleUserIds.remove(message.getSenderId()); - int readCount = getSumValue(visibleUserIds, - uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, - Integer::sum, 0); - message.setReadCount(readCount); - } - } - - @Override - public void readGroupMessages(Long userId, Long groupId, Long messageId) { - Assert.notNull(messageId, "已读消息编号不能为空"); - // 1. 校验用户在群中(权限校验) - groupMemberService.validateMemberInGroup(groupId, userId); - - // 2. 已读位置未前进,直接返回 - Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId); - if (prevMaxMessageId != null && prevMaxMessageId >= messageId) { - return; - } - // 3. 更新 Redis 群已读位置 - groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId); - - // 4. 异步发送 READ 事件 + 刷新范围内的群回执 - getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { - // 1.1 校验消息存在 - ImGroupMessageDO message = validateGroupMessageExists(messageId); - // 1.2 只能撤回自己发送的消息 - if (ObjUtil.notEqual(message.getSenderId(), userId)) { - throw exception(MESSAGE_RECALL_DENIED); - } - // 1.3 不能重复撤回 - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - throw exception(MESSAGE_ALREADY_RECALLED); - } - // 1.4 只允许撤回限定时间内的消息 - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); - } - // 1.5 校验撤回人仍在群中 - groupMemberService.validateMemberInGroup(message.getGroupId(), userId); - - // 2. 更新原消息状态为撤回 - groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); - - // 3. 插入一条 TIP_TEXT 消息作为撤回提示 - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); - ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(userId).setGroupId(message.getGroupId()) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(JsonUtils.toJsonString(recallContent)) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); - groupMessageMapper.insert(recallMessage); - - // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示) - sendGroupMessageEvent(recallMessage); - return recallMessage; - } - - // TODO @芋艿:这个方法在优化下; - @Override - public List getGroupReadUsers(Long userId, Long groupId, Long messageId) { - // 1.1 校验用户在群中(权限校验) - groupMemberService.validateMemberInGroup(groupId, userId); - // 1.2 获取消息 - ImGroupMessageDO message = groupMessageMapper.selectById(messageId); - if (message == null) { - return Collections.emptyList(); - } - - // 2. 获取所有成员和已读位置 - List allMembers = groupMemberService.getGroupMemberListByGroupId(groupId); - Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); - - // 3. 计算该消息的可见成员集合(排除发送者自己) - Set visibleUserIds = getVisibleUserIds(message, allMembers); - visibleUserIds.remove(message.getSenderId()); - - // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 - // DONE @AI:根据返回结果,进行简化;应该都是 Long、Long;——RedisDAO 已返回 Map,直接使用 - List readUserIds = new ArrayList<>(); - allPositions.forEach((uid, readMaxMessageId) -> { - if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) { - readUserIds.add(uid); - } - }); - return readUserIds; - } - - @Override - public List getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { - // 1. 校验用户在群中 - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); - - // 2. 查询历史消息(仅入群之后) - return groupMessageMapper.selectHistoryList( - reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); - } - - // ========== 异步 WebSocket 推送 ========== - - /** - * 发送群聊消息 WebSocket 事件 - *

- * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: - *

    - *
  • 所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;
  • - *
  • ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;
  • - *
- * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 - * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, - * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 - *

- * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 - */ - public void sendGroupMessageEvent(ImGroupMessageDO message) { - ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); - // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) - List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); - Set targetUserIds = getVisibleUserIds(message, memberUserIds); - imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); - } - - /** - * 发送已读 + 刷新群回执 WebSocket 事件 - * - * @param userId 当前用户编号 - * @param groupId 群编号 - * @param prevMaxMessageId 上次已读位置 - * @param newMaxMessageId 本次已读位置 - */ - @Async - @SuppressWarnings("DataFlowIssue") - public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) { - // 1. 发送 READ 事件给自己的其他终端(多端同步) - imWebSocketService.sendGroupMessageAsync(userId, - ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId)); - - // 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息 - List pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt( - groupId, prevMaxMessageId, newMaxMessageId); - if (CollUtil.isEmpty(pendingMessages)) { - return; - } - List activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId); - Map allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId); - for (ImGroupMessageDO message : pendingMessages) { - // 2.1.1 统计可见成员中的已读人数 - Set visibleUserIds = getVisibleUserIds(message, activeMembers); - visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读 - if (CollUtil.isEmpty(visibleUserIds)) { - continue; - } - int readCount = getSumValue(visibleUserIds, - uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, - Integer::sum, 0); - // 2.1.2 全部已读 → 标记回执完成 - Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); - if (readCount >= visibleUserIds.size()) { - newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); - groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) - .setReceiptStatus(newReceiptStatus)); - } - - // 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度) - imWebSocketService.sendGroupMessageAsync(message.getSenderId(), - ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus)); - } - } - - // ========== 私有工具方法 ========== - - /** - * 校验群聊消息存在 - * - * @param messageId 消息编号 - * @return 群聊消息 - */ - private ImGroupMessageDO validateGroupMessageExists(Long messageId) { - ImGroupMessageDO message = groupMessageMapper.selectById(messageId); - if (message == null) { - throw exception(MESSAGE_NOT_EXISTS); - } - return message; - } - - /** - * 判断一条群消息对某个群成员是否可见 - * - * @param msg 消息 - * @param member 群成员 - * @param userId 当前用户编号(用于定向消息过滤) - * @return 是否可见 - */ - private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) { - if (member == null) { - return false; - } - // 1. 入群时间晚于消息发送时间 → 不可见 - if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) { - return false; - } - // 2. 已退群且退群时间早于消息发送时间 → 不可见 - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) - && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) { - return false; - } - // 3.1 无定向接收列表 → 全员可见 - if (CollUtil.isEmpty(msg.getReceiverUserIds())) { - return true; - } - // 3.2 当前用户在定向列表中,或本人即发送者 → 可见 - return msg.getReceiverUserIds().contains(userId) - || ObjUtil.equal(msg.getSenderId(), userId); - } - - /** - * 计算一条群消息的可见成员集合(含发送者) - */ - private Set getVisibleUserIds(ImGroupMessageDO message, List members) { - return convertSet(members, ImGroupMemberDO::getUserId, - member -> isMessageVisible(message, member, member.getUserId())); - } - - /** - * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 - *

- * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, - * 只应用 {@code receiverUserIds} 定向过滤;语义与 - * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 - */ - private Set getVisibleUserIds(ImGroupMessageDO message, Collection memberUserIds) { - if (CollUtil.isEmpty(memberUserIds)) { - return new HashSet<>(); - } - // 无定向接收列表 → 全员可见 - if (CollUtil.isEmpty(message.getReceiverUserIds())) { - return new HashSet<>(memberUserIds); - } - // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) - Set allowed = new HashSet<>(message.getReceiverUserIds()); - if (message.getSenderId() != null) { - allowed.add(message.getSenderId()); - } - Set result = new HashSet<>(); - for (Long userId : memberUserIds) { - if (allowed.contains(userId)) { - result.add(userId); - } - } - return result; - } - - @Override - public void deleteReadMaxMessageId(Long groupId, Long userId) { - groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId); - } - - @Override - public void deleteReadMaxMessageIds(Long groupId, Collection userIds) { - groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds); - } - - @Override - public void deleteReadMaxMessageIdMap(Long groupId) { - groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); - } - - @Override - public void sendTipGroupMessage(Long senderId, Long groupId, Set memberUserIds, String content) { - // 1. 插入 TIP_TEXT 消息 - ImGroupMessageDO tipMessage = new ImGroupMessageDO() - .setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(senderId).setGroupId(groupId) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); - groupMessageMapper.insert(tipMessage); - - // 2. 推送给指定用户 - imWebSocketService.sendGroupMessageAsync(memberUserIds, - ImGroupMessageDTO.ofSend(tipMessage)); - } - - private ImGroupMessageServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); - } - -} \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java deleted file mode 100644 index 0f21bd1..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java +++ /dev/null @@ -1,72 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; - -import java.util.List; - -/** - * IM 私聊消息 Service 接口 - * - * @author 芋道源码 - */ -public interface ImPrivateMessageService { - - /** - * 发送私聊消息 - * - * @param senderId 发送人编号 - * @param reqVO 发送请求 - * @return 消息 - */ - ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); - - /** - * 拉取私聊消息(增量) - * - * @param userId 当前用户编号 - * @param minId 最小消息 id(不含) - * @param size 拉取数量 - * @return 消息列表 - */ - List pullPrivateMessageList(Long userId, Long minId, Integer size); - - /** - * 标记私聊消息已读 - * - * @param userId 当前用户编号 - * @param receiverId 接收方用户编号(对方) - */ - void readPrivateMessages(Long userId, Long receiverId); - - /** - * 撤回私聊消息 - * - * @param userId 当前用户编号 - * @param messageId 消息编号 - * @return 撤回后的消息 - */ - ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); - - /** - * 查询私聊历史消息(游标拉取) - * - * @param userId 当前用户编号 - * @param reqVO 拉取请求 - * @return 消息列表(按 id 倒序) - */ - List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); - - /** - * 发送系统提示消息(TIP_TEXT) - *

- * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。 - * - * @param senderId 发送人编号 - * @param receiverId 接收人编号 - * @param content 提示文本 - */ - void sendTipPrivateMessage(Long senderId, Long receiverId, String content); - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java deleted file mode 100644 index b785ee9..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ /dev/null @@ -1,182 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.ObjUtil; -import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; -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.util.List; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; - -/** - * IM 私聊消息 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { - - @Resource - private ImPrivateMessageMapper privateMessageMapper; - - @Resource - private ImFriendService friendService; - @Resource - private ImSensitiveWordService sensitiveWordService; - - @Resource - private ImWebSocketService imWebSocketService; - - @Override - public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO) { - // 1.1 幂等校验:根据 senderId + clientMessageId 查重 - ImPrivateMessageDO existing = privateMessageMapper.selectBySenderIdAndClientMessageId( - senderId, reqVO.getClientMessageId()); - if (existing != null) { - log.info("[sendPrivateMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", - senderId, reqVO.getClientMessageId(), existing.getId()); - return existing; - } - // 1.2 好友校验 - if (!friendService.isFriend(senderId, reqVO.getReceiverId())) { - throw exception(FRIEND_NOT_FRIEND); - } - // 1.3 文本消息敏感词过滤 - if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { - sensitiveWordService.validateText(reqVO.getContent()); - } - - // 2. 构建并保存消息 - ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); - privateMessageMapper.insert(message); - - // 3. WebSocket 异步推送(接收方 + 发送方多端同步) - ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); - imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); - imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage); - return message; - } - - @Override - public List pullPrivateMessageList(Long userId, Long minId, Integer size) { - if (size > MESSAGE_MAX_PULL_SIZE) { - throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); - } - // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 - LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_PRIVATE_PULL_MAX_DAYS); - - // 根据 minId 和 minSendTime 拉取消息,避免 minId 恰好被发出后才拉取,导致漏消息 - List messages = privateMessageMapper.selectListByMinId(userId, minId, minSendTime, size); - log.info("[pullPrivateMessageList][userId({}) minId({}) size({}) result({})]", - userId, minId, size, messages.size()); - return messages; - } - - // TODO @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 - @Override - public void readPrivateMessages(Long userId, Long receiverId) { - // 1.1 查询未读消息列表 - List unreadMessages = privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus( - receiverId, userId, ImMessageStatusEnum.UNREAD.getStatus()); - if (CollUtil.isEmpty(unreadMessages)) { - return; - } - // 1.2 根据 id in + status 条件更新,避免并发问题 - List messageIds = convertList(unreadMessages, ImPrivateMessageDO::getId); - privateMessageMapper.updateByIdsAndStatus(messageIds, ImMessageStatusEnum.UNREAD.getStatus(), - new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); - - // 2. 异步发送 READ + RECEIPT 事件(携带已读位置) - Long maxReadId = CollUtil.max(messageIds); - imWebSocketService.sendPrivateMessageAsync(userId, - ImPrivateMessageDTO.ofRead(userId, receiverId, maxReadId)); - imWebSocketService.sendPrivateMessageAsync(receiverId, - ImPrivateMessageDTO.ofReceipt(userId, receiverId, maxReadId)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { - // 1.1 校验消息存在 - ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); - if (message == null) { - throw exception(MESSAGE_NOT_EXISTS); - } - // 1.2 只能撤回自己发送的消息 - if (ObjUtil.notEqual(message.getSenderId(), userId)) { - throw exception(MESSAGE_RECALL_DENIED); - } - // 1.3 不能重复撤回 - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - throw exception(MESSAGE_ALREADY_RECALLED); - } - // 1.4 只允许撤回限定时间内的消息 - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); - } - - // 2. 更新原消息状态为撤回 - privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); - - // 3. 插入一条 TIP_TEXT 消息作为撤回提示 - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); - ImPrivateMessageDO tipMessage = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) - .setSenderId(userId).setReceiverId(message.getReceiverId()) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(JsonUtils.toJsonString(recallContent)) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); - privateMessageMapper.insert(tipMessage); - - // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示) - ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(tipMessage); - imWebSocketService.sendPrivateMessageAsync(tipMessage.getReceiverId(), websocketMessage); - imWebSocketService.sendPrivateMessageAsync(tipMessage.getSenderId(), websocketMessage); - return tipMessage; - } - - @Override - public List getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { - return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); - } - - @Override - public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { - // 1. 插入 TIP_TEXT 消息 - ImPrivateMessageDO tipMessage = new ImPrivateMessageDO() - .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId) - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); - privateMessageMapper.insert(tipMessage); - - // 2. 推送给双方 - ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage); - imWebSocketService.sendPrivateMessageAsync(receiverId, dto); - imWebSocketService.sendPrivateMessageAsync(senderId, dto); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java deleted file mode 100644 index 25bd621..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordService.java +++ /dev/null @@ -1,17 +0,0 @@ -package cn.iocoder.yudao.module.im.service.sensitiveword; - -/** - * IM 敏感词 Service 接口 - * - * @author 芋道源码 - */ -public interface ImSensitiveWordService { - - /** - * 校验文本是否包含敏感词 - * - * @param text 待校验文本 - */ - void validateText(String text); - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java deleted file mode 100644 index cacb7a6..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java +++ /dev/null @@ -1,77 +0,0 @@ -package cn.iocoder.yudao.module.im.service.sensitiveword; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; -import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.validation.annotation.Validated; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; - -// DONE @AI:已实现内存化,启动时加载 + 定时刷新 -/** - * IM 敏感词 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { - - @Resource - private ImSensitiveWordMapper imSensitiveWordMapper; - - /** - * 内存缓存的敏感词列表 - */ - private volatile List sensitiveWords = Collections.emptyList(); - - /** - * 初始化:启动时加载敏感词到内存 - */ - @PostConstruct - public void init() { - loadSensitiveWords(); - } - - /** - * 定时刷新:每 5 分钟重新加载一次 - */ - @Scheduled(fixedDelay = 5 * 60 * 1000, initialDelay = 5 * 60 * 1000) - public void refresh() { - loadSensitiveWords(); - } - - private void loadSensitiveWords() { - List enabledWords = imSensitiveWordMapper.selectListByStatus( - CommonStatusEnum.ENABLE.getStatus()); - sensitiveWords = enabledWords.stream() - .map(ImSensitiveWordDO::getWord) - .collect(Collectors.toList()); - log.info("[loadSensitiveWords][加载 IM 敏感词 {} 个]", sensitiveWords.size()); - } - - @Override - public void validateText(String text) { - if (text == null || text.isEmpty()) { - return; - } - // 内存匹配 - for (String word : sensitiveWords) { - if (text.contains(word)) { - throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED); - } - } - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java deleted file mode 100644 index f7457fe..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java +++ /dev/null @@ -1,44 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket; - -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; - -import java.util.Collection; -import java.util.Collections; - -/** - * IM WebSocket 推送 Service 接口 - *

- * 统一封装 WebSocket 消息推送,所有方法默认异步执行。 - * - * @author 芋道源码 - */ -public interface ImWebSocketService { - - /** - * 异步推送私聊消息给指定用户 - * - * @param userId 目标用户编号 - * @param dto 私聊消息 DTO - */ - void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto); - - /** - * 异步推送群聊消息给指定用户 - * - * @param userId 目标用户编号 - * @param dto 群聊消息 DTO - */ - default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) { - sendGroupMessageAsync(Collections.singleton(userId), dto); - } - - /** - * 异步批量推送群聊消息给多个用户 - * - * @param userIds 目标用户编号列表 - * @param dto 群聊消息 DTO - */ - void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto); - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java deleted file mode 100644 index ab8da84..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java +++ /dev/null @@ -1,106 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -import org.springframework.validation.annotation.Validated; - -import java.util.Collection; - -// TODO @芋艿:后续要分析下,是不是要拆分下 notify(类似好友、群的变更) -/** - * IM WebSocket 推送 Service 实现类 - *

- * 当调用方处于事务中时,推送会延迟到事务提交后再异步执行, - * 避免客户端收到 WebSocket 消息时数据库变更尚未可见。 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class ImWebSocketServiceImpl implements ImWebSocketService { - - @Resource - private WebSocketMessageSender webSocketMessageSender; - - @Override - public void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) { - // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交 - // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理) - executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userId, dto)); - } - - @Override - public void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto) { - executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto)); - } - - /** - * 异步发送私聊 WebSocket 消息 - */ - @Async - public void doSendPrivateMessage(Long userId, ImPrivateMessageDTO dto) { - try { - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, - ImPrivateMessageDTO.TYPE, dto); - } catch (Exception e) { - log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e); - } - } - - /** - * 异步发送群聊 WebSocket 消息 - */ - @Async - public void doSendGroupMessage(Collection userIds, ImGroupMessageDTO dto) { - for (Long userId : userIds) { - try { - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, - ImGroupMessageDTO.TYPE, dto); - } catch (Exception e) { - log.error("[doSendGroupMessage][userId({}) dto({}) 发送失败]", userId, dto, e); - } - } - } - - /** - * 事务感知的任务调度: - * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 - * - 无事务:直接执行(如非 @Transactional 方法中的调用) - * - * @param task 待执行的推送任务(内部通过 getSelf() 走 @Async 异步执行) - */ - private void executeAfterTransaction(Runnable task) { - // 情况一:没有事务,直接执行 - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - task.run(); - return; - } - // 情况二:有事务,注册 afterCommit 事件,在事务提交后执行 - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - - @Override - public void afterCommit() { - task.run(); - } - - }); - } - - /** - * 获得自身的代理对象,解决 @Async AOP 代理问题 - */ - private ImWebSocketServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java deleted file mode 100644 index 9d06922..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java +++ /dev/null @@ -1,179 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto; - -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * IM 群聊消息 WebSocket 统一推送 DTO - *

- * 发送、已读、回执都通过 {@link #type} 字段区分, - * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 - * - * @author 芋道源码 - */ -@Data -public class ImGroupMessageDTO { - - public static final String TYPE = "im-group-message"; - - /** - * 消息编号 - *

- * 普通消息:当前消息 id - * READ:无(群已读不需要) - * RECEIPT:需要回执的目标消息 id - */ - private Long id; - /** - * 客户端消息编号 - */ - private String clientMessageId; - /** - * 发送人编号 - */ - private Long senderId; - /** - * 群编号 - */ - private Long groupId; - /** - * 消息类型 - */ - private Integer type; - /** - * 消息内容 - */ - private String content; - /** - * 消息状态 - */ - private Integer status; - /** - * 发送时间 - */ - private LocalDateTime sendTime; - /** - * @ 目标用户编号列表 - */ - private List atUserIds; - /** - * 定向接收用户编号列表 - */ - private List receiverUserIds; - /** - * 群回执状态 - */ - private Integer receiptStatus; - /** - * 群回执已读人数 - */ - private Integer readCount; - - // ========== 静态工厂方法 ========== - - /** - * 构建发送消息 DTO - * - * @param message 群聊消息 DO - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) { - return BeanUtils.toBean(message, ImGroupMessageDTO.class); - } - - /** - * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个群") - * - * @param senderId 当前用户编号 - * @param groupId 群编号 - * @param readId 已读位置(最大已读消息编号) - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) { - return new ImGroupMessageDTO() - .setId(readId).setType(ImMessageTypeEnum.READ.getType()) - .setSenderId(senderId).setGroupId(groupId); - } - - /** - * 构建群回执 DTO(广播回执状态和已读人数) - * - * @param messageId 消息编号 - * @param groupId 群编号 - * @param readCount 已读人数 - * @param receiptStatus 回执状态 - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId, - Integer readCount, Integer receiptStatus) { - return new ImGroupMessageDTO() - .setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType()) - .setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus); - } - - // ==================== 群变更相关 ==================== - - /** - * 构建群创建推送 DTO(通知群成员"有一个新群") - * - * @param senderId 创建者(群主)用户编号 - * @param groupId 群编号 - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofGroupCreate(Long senderId, Long groupId) { - return new ImGroupMessageDTO() - .setType(ImMessageTypeEnum.GROUP_CREATE.getType()) - .setSenderId(senderId).setGroupId(groupId) - .setSendTime(LocalDateTime.now()); - } - - /** - * 构建群信息变更推送 DTO(通知所有群成员"群信息变了") - * - * @param senderId 操作者用户编号 - * @param groupId 群编号 - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofGroupUpdate(Long senderId, Long groupId) { - return new ImGroupMessageDTO() - .setType(ImMessageTypeEnum.GROUP_UPDATE.getType()) - .setSenderId(senderId).setGroupId(groupId) - .setSendTime(LocalDateTime.now()); - } - - /** - * 构建群删除推送 DTO(通知群成员"群已移除") - *

- * 适用于群解散、退群、踢出等场景 - * - * @param senderId 操作者用户编号 - * @param groupId 群编号 - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofGroupDelete(Long senderId, Long groupId) { - return new ImGroupMessageDTO() - .setType(ImMessageTypeEnum.GROUP_DELETE.getType()) - .setSenderId(senderId).setGroupId(groupId) - .setSendTime(LocalDateTime.now()); - } - - /** - * 构建群成员信息变更推送 DTO(多端同步:通知自己"在某群的个人设置变了") - * - * @param senderId 当前用户编号 - * @param groupId 群编号 - * @return 群聊 DTO - */ - public static ImGroupMessageDTO ofGroupMemberUpdate(Long senderId, Long groupId) { - return new ImGroupMessageDTO() - .setType(ImMessageTypeEnum.GROUP_MEMBER_UPDATE.getType()) - .setSenderId(senderId).setGroupId(groupId) - .setSendTime(LocalDateTime.now()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java deleted file mode 100644 index a609561..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java +++ /dev/null @@ -1,147 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto; - -import cn.iocoder.yudao.framework.common.util.object.BeanUtils; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import lombok.Data; -import lombok.experimental.Accessors; - -import java.time.LocalDateTime; - -/** - * IM 私聊消息 WebSocket 统一推送 DTO - *

- * 发送、已读、回执、撤回都通过 {@link #type} 字段区分, - * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 - * - * @author 芋道源码 - */ -@Data -@Accessors(chain = true) -public class ImPrivateMessageDTO { - - public static final String TYPE = "im-private-message"; - - /** - * 消息编号 - *

- * 普通消息:当前消息 id - * READ:已读位置(我已读到这条消息) - * RECEIPT:已读位置(对方已读到这条消息) - * RECALL:被撤回的原消息 id - */ - private Long id; - /** - * 客户端消息编号 - */ - private String clientMessageId; - /** - * 发送人编号 - */ - private Long senderId; - /** - * 接收人编号 - */ - private Long receiverId; - /** - * 消息类型 - */ - private Integer type; - /** - * 消息内容 - */ - private String content; - /** - * 消息状态 - */ - private Integer status; - /** - * 发送时间 - */ - private LocalDateTime sendTime; - - // ========== 静态工厂方法 ========== - - /** - * 构建发送消息 DTO - * - * @param message 私聊消息 DO - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) { - return BeanUtils.toBean(message, ImPrivateMessageDTO.class); - } - - /** - * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话") - * - * @param senderId 当前用户编号 - * @param receiverId 对方用户编号 - * @param readId 已读位置(最大已读消息编号) - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) { - return new ImPrivateMessageDTO() - .setId(readId).setType(ImMessageTypeEnum.READ.getType()) - .setSenderId(senderId).setReceiverId(receiverId); - } - - /** - * 构建已读回执 DTO(通知对方"我已读了你的消息") - * - * @param senderId 已读方的用户编号 - * @param receiverId 对方用户编号 - * @param readId 已读位置(最大已读消息编号) - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) { - return new ImPrivateMessageDTO() - .setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType()) - .setSenderId(senderId).setReceiverId(receiverId); - } - - // ==================== 好友变更相关 ==================== - - /** - * 构建好友添加推送 DTO(通知对方"你有一位新好友") - * - * @param userId 当前用户编号(即发起添加的用户) - * @param friendUserId 好友的用户编号(即接收通知的用户) - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofFriendAdd(Long userId, Long friendUserId) { - return new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.FRIEND_ADD.getType()) - .setSenderId(userId).setReceiverId(friendUserId) - .setSendTime(LocalDateTime.now()); - } - - /** - * 构建好友删除推送 DTO(通知对方"好友关系已解除") - * - * @param userId 当前用户编号(即发起删除的用户) - * @param friendUserId 好友的用户编号(即接收通知的用户) - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofFriendDelete(Long userId, Long friendUserId) { - return new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.FRIEND_DELETE.getType()) - .setSenderId(userId).setReceiverId(friendUserId) - .setSendTime(LocalDateTime.now()); - } - - /** - * 构建好友更新推送 DTO(通知自己多端同步好友属性变更) - * - * @param userId 当前用户编号 - * @param friendUserId 好友的用户编号 - * @return 私聊 DTO - */ - public static ImPrivateMessageDTO ofFriendUpdate(Long userId, Long friendUserId) { - return new ImPrivateMessageDTO() - .setType(ImMessageTypeEnum.FRIEND_UPDATE.getType()) - .setSenderId(userId).setReceiverId(friendUserId) - .setSendTime(LocalDateTime.now()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java deleted file mode 100644 index 884e1b0..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java +++ /dev/null @@ -1,24 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 语音消息内容 - */ -@Data -public class AudioMessage { - - /** - * 语音文件 URL - */ - private String url; - /** - * 语音时长(秒) - */ - private Integer duration; - /** - * 文件大小(字节) - */ - private Long size; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java deleted file mode 100644 index e567b27..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/CardMessage.java +++ /dev/null @@ -1,24 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 名片消息内容 - */ -@Data -public class CardMessage { - - /** - * 用户编号 - */ - private Long userId; - /** - * 用户昵称 - */ - private String nickname; - /** - * 用户头像 - */ - private String avatar; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java deleted file mode 100644 index 7965179..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java +++ /dev/null @@ -1,28 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 文件消息内容 - */ -@Data -public class FileMessage { - - /** - * 文件 URL - */ - private String url; - /** - * 文件名 - */ - private String name; - /** - * 文件大小(字节) - */ - private Long size; - /** - * 文件类型(扩展名) - */ - private String type; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java deleted file mode 100644 index c3d272d..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java +++ /dev/null @@ -1,32 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 图片消息内容 - */ -@Data -public class ImageMessage { - - /** - * 图片 URL - */ - private String url; - /** - * 缩略图 URL - */ - private String thumbnailUrl; - /** - * 图片宽度 - */ - private Integer width; - /** - * 图片高度 - */ - private Integer height; - /** - * 图片大小(字节) - */ - private Long size; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java deleted file mode 100644 index 8c36d1a..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/LocationMessage.java +++ /dev/null @@ -1,24 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 位置消息内容 - */ -@Data -public class LocationMessage { - - /** - * 位置描述 - */ - private String description; - /** - * 经度 - */ - private Double longitude; - /** - * 纬度 - */ - private Double latitude; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java deleted file mode 100644 index 5df78f2..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/RecallMessage.java +++ /dev/null @@ -1,18 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; -import lombok.experimental.Accessors; - -/** - * 撤回提示消息内容 - */ -@Data -@Accessors(chain = true) -public class RecallMessage { - - /** - * 被撤回的原消息编号 - */ - private Long messageId; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java deleted file mode 100644 index 747fabd..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java +++ /dev/null @@ -1,16 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 文本消息内容 - */ -@Data -public class TextMessage { - - /** - * 文本内容 - */ - private String content; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java deleted file mode 100644 index 3aac314..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java +++ /dev/null @@ -1,36 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket.dto.message; - -import lombok.Data; - -/** - * 视频消息内容 - */ -@Data -public class VideoMessage { - - /** - * 视频文件 URL - */ - private String url; - /** - * 视频封面 URL - */ - private String coverUrl; - /** - * 视频时长(秒) - */ - private Integer duration; - /** - * 视频宽度 - */ - private Integer width; - /** - * 视频高度 - */ - private Integer height; - /** - * 文件大小(字节) - */ - private Long size; - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java deleted file mode 100644 index 2ce7dec..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.group; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IM 群成员 Mapper 单元测试 - * - * @author 芋道源码 - */ -public class ImGroupMemberMapperTest extends BaseDbUnitTest { - - @Resource - private ImGroupMemberMapper mapper; - - // ========== selectQuitListByUserId ========== - - @Test - public void testSelectQuitListByUserId_onlyDisabled() { - // 准备:一条 ENABLE + 一条 DISABLE - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); - ImGroupMemberDO quit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); - quit.setQuitTime(LocalDateTime.now().minusDays(1)); - mapper.insert(quit); - - // 调用:minQuitTime 为 null,不按时间过滤 - List result = mapper.selectQuitListByUserId(1L, null); - - // 断言:只返回退群记录 - assertEquals(1, result.size()); - assertEquals(quit.getId(), result.get(0).getId()); - } - - @Test - public void testSelectQuitListByUserId_filterByMinQuitTime() { - // 准备:两条退群记录,时间一早一晚 - LocalDateTime old = LocalDateTime.now().minusDays(10); - LocalDateTime recent = LocalDateTime.now().minusDays(1); - ImGroupMemberDO oldQuit = buildMember(10L, 1L, CommonStatusEnum.DISABLE); - oldQuit.setQuitTime(old); - mapper.insert(oldQuit); - ImGroupMemberDO recentQuit = buildMember(20L, 1L, CommonStatusEnum.DISABLE); - recentQuit.setQuitTime(recent); - mapper.insert(recentQuit); - - // 调用:minQuitTime = now - 5 天,只返回近期退群 - List result = mapper.selectQuitListByUserId(1L, LocalDateTime.now().minusDays(5)); - assertEquals(1, result.size()); - assertEquals(recentQuit.getId(), result.get(0).getId()); - - // 调用:minQuitTime 为 null,不过滤 - List all = mapper.selectQuitListByUserId(1L, null); - assertEquals(2, all.size()); - } - - // ========== updateByGroupIdAndUserIdsAndStatus ========== - - @Test - public void testUpdateByGroupIdAndUserIdsAndStatus_onlyUpdatesOldStatusMatches() { - // 准备:u1 ENABLE,u2 DISABLE(已退群,不应更新) - ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); - mapper.insert(u1); - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.DISABLE); - u2.setQuitTime(LocalDateTime.now().minusDays(5)); - mapper.insert(u2); - - // 调用:把 ENABLE 状态的成员移除 - LocalDateTime now = LocalDateTime.now(); - mapper.updateByGroupIdAndUserIdsAndStatus(10L, List.of(1L, 2L), - CommonStatusEnum.ENABLE.getStatus(), - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()).setQuitTime(now)); - - // 断言:u1 被更新 - ImGroupMemberDO after1 = mapper.selectById(u1.getId()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), after1.getStatus()); - assertNotNull(after1.getQuitTime()); - // 断言:u2 的 quitTime 不变(仍为 5 天前) - ImGroupMemberDO after2 = mapper.selectById(u2.getId()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), after2.getStatus()); - assertTrue(after2.getQuitTime().isBefore(now.minusDays(1))); - } - - // ========== updateByGroupIdAndStatus ========== - - @Test - public void testUpdateByGroupIdAndStatus_allActiveInGroup() { - // 准备:群 10 有 2 个活跃 + 1 个已退;群 20 有 1 个活跃 - ImGroupMemberDO a1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); - mapper.insert(a1); - ImGroupMemberDO a2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); - mapper.insert(a2); - ImGroupMemberDO a3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); - a3.setQuitTime(LocalDateTime.now().minusDays(5)); - mapper.insert(a3); - ImGroupMemberDO b1 = buildMember(20L, 1L, CommonStatusEnum.ENABLE); - mapper.insert(b1); - - // 调用:解散群 10 - mapper.updateByGroupIdAndStatus(10L, CommonStatusEnum.ENABLE.getStatus(), - new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) - .setQuitTime(LocalDateTime.now())); - - // 断言:群 10 的活跃成员全部变为 DISABLE,群 20 的不受影响 - assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a1.getId()).getStatus()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), mapper.selectById(a2.getId()).getStatus()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), mapper.selectById(b1.getId()).getStatus()); - } - - // ========== 工具方法 ========== - - private ImGroupMemberDO buildMember(Long groupId, Long userId, CommonStatusEnum status) { - return ImGroupMemberDO.builder() - .groupId(groupId).userId(userId) - .status(status.getStatus()) - .joinTime(LocalDateTime.now().minusDays(30)) - .build(); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java deleted file mode 100644 index c14b761..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java +++ /dev/null @@ -1,287 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.message; - -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IM 群聊消息 Mapper 单元测试 - * - * @author 芋道源码 - */ -public class ImGroupMessageMapperTest extends BaseDbUnitTest { - - @Resource - private ImGroupMessageMapper mapper; - - private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); - - // ========== selectListByMinId ========== - - @Test - public void testSelectListByMinId_onlyReturnsGroupsInIdList() { - // 准备:群 10 的消息 + 群 20 的消息 - ImGroupMessageDO msg10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg10); - ImGroupMessageDO msg20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg20); - - // 调用:只查群 10 - List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); - - // 断言:只有 msg10 - assertEquals(1, result.size()); - assertEquals(msg10.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByMinId_excludeRecall() { - // 准备:一条正常 + 一条撤回 - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(normal); - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); - mapper.insert(recalled); - - List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 100); - - assertEquals(1, result.size()); - assertEquals(normal.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByMinId_sendTimeWindow() { - // 准备:一条在窗口内,一条在窗口外 - ImGroupMessageDO inWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - inWindow.setSendTime(LocalDateTime.now().minusDays(1)); - mapper.insert(inWindow); - ImGroupMessageDO outWindow = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - outWindow.setSendTime(LocalDateTime.now().minusDays(40)); - mapper.insert(outWindow); - - List result = mapper.selectListByMinId(List.of(10L), 0L, - LocalDateTime.now().minusDays(30), 100); - - assertEquals(1, result.size()); - assertEquals(inWindow.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByMinId_sortAscLimit() { - // 准备:插入 3 条 - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m3); - - // 调用:size=2,返回 id 最小的 2 条 - List result = mapper.selectListByMinId(List.of(10L), 0L, FAR_PAST, 2); - - assertEquals(2, result.size()); - assertTrue(result.get(0).getId() < result.get(1).getId()); - assertEquals(m1.getId(), result.get(0).getId()); - assertEquals(m2.getId(), result.get(1).getId()); - } - - // ========== selectListByGroupIdAndMinIdAndQuitTimeBefore ========== - - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_onlyReturnsBeforeQuit() { - // 准备:退群时间 = now-1 day,插入一条"退群前",一条"退群后" - LocalDateTime quitTime = LocalDateTime.now().minusDays(1); - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - before.setSendTime(LocalDateTime.now().minusDays(2)); - mapper.insert(before); - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - after.setSendTime(LocalDateTime.now().minusHours(1)); - mapper.insert(after); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); - - // 断言:只返回退群前消息 - assertEquals(1, result.size()); - assertEquals(before.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_boundaryEqualsQuitTime() { - // 准备:消息发送时间恰好等于退群时间(le 语义应包含该条) - LocalDateTime quitTime = LocalDateTime.now().minusDays(1).withNano(0); - ImGroupMessageDO boundary = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - boundary.setSendTime(quitTime); - mapper.insert(boundary); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); - - assertEquals(1, result.size()); - } - - @Test - public void testSelectListByGroupIdAndMinIdAndQuitTimeBefore_otherGroupExcluded() { - // 准备:群 10 和群 20 各一条消息 - LocalDateTime quitTime = LocalDateTime.now(); - ImGroupMessageDO in10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - in10.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(in10); - ImGroupMessageDO in20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); - in20.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(in20); - - List result = mapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( - 10L, 0L, FAR_PAST, quitTime, 100); - - assertEquals(1, result.size()); - assertEquals(in10.getId(), result.get(0).getId()); - } - - // ========== selectHistoryList ========== - - @Test - public void testSelectHistoryList_basic() { - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(m2); - // 别的群 - ImGroupMessageDO other = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(other); - - List result = mapper.selectHistoryList(10L, null, 100, null); - - // 断言:只返回 10 群的,按 id 倒序 - assertEquals(2, result.size()); - assertTrue(result.get(0).getId() > result.get(1).getId()); - } - - @Test - public void testSelectHistoryList_excludeRecall() { - ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(normal); - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); - mapper.insert(recalled); - - List result = mapper.selectHistoryList(10L, null, 100, null); - - assertEquals(1, result.size()); - assertEquals(normal.getId(), result.get(0).getId()); - } - - @Test - public void testSelectHistoryList_maxIdCursor() { - ImGroupMessageDO m1 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(m3); - - List result = mapper.selectHistoryList(10L, m3.getId(), 100, null); - - // 断言:只返回 id < m3.id 的,即 m1、m2 - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(m -> m.getId() < m3.getId())); - } - - @Test - public void testSelectHistoryList_filterByJoinTime() { - // 准备:一条在入群前,一条在入群后 - LocalDateTime joinTime = LocalDateTime.now().minusHours(1); - ImGroupMessageDO before = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - before.setSendTime(LocalDateTime.now().minusHours(2)); - mapper.insert(before); - ImGroupMessageDO after = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - after.setSendTime(LocalDateTime.now().minusMinutes(30)); - mapper.insert(after); - - List result = mapper.selectHistoryList(10L, null, 100, joinTime); - - // 断言:入群前消息不可见 - assertEquals(1, result.size()); - assertEquals(after.getId(), result.get(0).getId()); - } - - // ========== selectListByGroupIdAndPendingReceipt ========== - - @Test - public void testSelectListByGroupIdAndPendingReceipt_basic() { - // 准备:待回执 + 已完成 + 无需回执,三种状态 - ImGroupMessageDO pending = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - pending.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(pending); - ImGroupMessageDO done = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - done.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus()); - mapper.insert(done); - ImGroupMessageDO noReceipt = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - noReceipt.setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); - mapper.insert(noReceipt); - - List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); - - // 断言:只返回 pending 记录 - assertEquals(1, result.size()); - assertEquals(pending.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByGroupIdAndPendingReceipt_windowAndRecall() { - // 准备:窗口外 + 撤回 + 窗口内 pending - ImGroupMessageDO belowRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - belowRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(belowRange); - ImGroupMessageDO inRange = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - inRange.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(inRange); - ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); - recalled.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(recalled); - - // 调用:minId = belowRange.id(排除), maxId = inRange.id(包含) - List result = mapper.selectListByGroupIdAndPendingReceipt( - 10L, belowRange.getId(), inRange.getId()); - - // 断言:只返回窗口内的非撤回 pending,即 inRange - assertEquals(1, result.size()); - assertEquals(inRange.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByGroupIdAndPendingReceipt_otherGroupExcluded() { - ImGroupMessageDO g10 = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - g10.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(g10); - ImGroupMessageDO g20 = buildMessage(20L, 1L, ImMessageStatusEnum.UNREAD); - g20.setReceiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()); - mapper.insert(g20); - - List result = mapper.selectListByGroupIdAndPendingReceipt(10L, null, Long.MAX_VALUE); - assertEquals(1, result.size()); - assertEquals(g10.getId(), result.get(0).getId()); - } - - // ========== 工具方法 ========== - - private ImGroupMessageDO buildMessage(Long groupId, Long senderId, ImMessageStatusEnum status) { - return ImGroupMessageDO.builder() - .clientMessageId("uuid-" + System.nanoTime()) - .senderId(senderId) - .groupId(groupId) - .type(0) - .content("{\"content\":\"test\"}") - .status(status.getStatus()) - .sendTime(LocalDateTime.now()) - .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) - .build(); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java deleted file mode 100644 index f49d18c..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java +++ /dev/null @@ -1,198 +0,0 @@ -package cn.iocoder.yudao.module.im.dal.mysql.message; - -import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IM 私聊消息 Mapper 单元测试 - * - * @author 芋道源码 - */ -public class ImPrivateMessageMapperTest extends BaseDbUnitTest { - - @Resource - private ImPrivateMessageMapper mapper; - - /** - * 一个足够早的时间作为 minSendTime,避免把测试数据过滤掉 - */ - private static final LocalDateTime FAR_PAST = LocalDateTime.of(2000, 1, 1, 0, 0, 0); - - // ========== selectListByMinId ========== - - @Test - public void testSelectListByMinId() { - // 准备:用户 1 发给用户 2 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg1); - // 用户 2 发给用户 1 - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg2); - // 用户 3 发给用户 4(不相关) - ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg3); - - // 调用:用户 1 从 id=0 拉取 - List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 100); - - // 断言:只包含与用户 1 相关的 2 条 - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(m -> - m.getSenderId().equals(1L) || m.getReceiverId().equals(1L))); - } - - @Test - public void testSelectListByMinId_withMinId() { - // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg2); - - // 调用:从 msg1.id 之后拉取 - List result = mapper.selectListByMinId(1L, msg1.getId(), FAR_PAST, 100); - - // 断言:只有 msg2 - assertEquals(1, result.size()); - assertEquals(msg2.getId(), result.get(0).getId()); - } - - @Test - public void testSelectListByMinId_limitSize() { - // 准备:插入 3 条 - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - - // 调用:limit 2 - List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2); - - // 断言 - assertEquals(2, result.size()); - } - - @Test - public void testSelectListByMinId_sendTimeFilter() { - // 准备:一条落在窗口内,一条落在窗口外 - ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - newMsg.setSendTime(LocalDateTime.now().minusDays(1)); - mapper.insert(newMsg); - ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - oldMsg.setSendTime(LocalDateTime.now().minusDays(40)); - mapper.insert(oldMsg); - - // 调用:窗口起点 = 30 天前 - List result = mapper.selectListByMinId(1L, 0L, - LocalDateTime.now().minusDays(30), 100); - - // 断言:只返回窗口内的消息 - assertEquals(1, result.size()); - assertEquals(newMsg.getId(), result.get(0).getId()); - } - - // ========== selectHistoryList ========== - - @Test - public void testSelectHistoryList_basic() { - // 准备:用户 1 <-> 用户 2 的消息 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg2); - // 用户 1 <-> 用户 3 的消息(不相关) - ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg3); - - // 调用 - List result = mapper.selectHistoryList(1L, 2L, null, 100); - - // 断言:只有与用户 2 对话的 2 条,按 id 倒序 - assertEquals(2, result.size()); - assertTrue(result.get(0).getId() > result.get(1).getId()); - } - - @Test - public void testSelectHistoryList_excludeRecall() { - // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL); - mapper.insert(msg2); - - // 调用 - List result = mapper.selectHistoryList(1L, 2L, null, 100); - - // 断言:撤回的不返回 - assertEquals(1, result.size()); - assertEquals(msg1.getId(), result.get(0).getId()); - } - - @Test - public void testSelectHistoryList_withMaxId() { - // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg2); - ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - mapper.insert(msg3); - - // 调用:从 msg3 往前拉 - List result = mapper.selectHistoryList(1L, 2L, msg3.getId(), 100); - - // 断言:只有 msg1 和 msg2 - assertEquals(2, result.size()); - assertTrue(result.stream().allMatch(m -> m.getId() < msg3.getId())); - } - - @Test - public void testSelectHistoryList_limit() { - // 准备 - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - - // 调用:limit 2 - List result = mapper.selectHistoryList(1L, 2L, null, 2); - - // 断言 - assertEquals(2, result.size()); - } - - @Test - public void testSelectHistoryList_bidirectional() { - // 准备:验证双向查询 - ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); // 对方发的 - mapper.insert(msg1); - - // 调用:以用户 1 的视角查 - List result = mapper.selectHistoryList(1L, 2L, null, 100); - - // 断言:能查到对方发来的消息 - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getSenderId()); - } - - // ========== 工具方法 ========== - - private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { - return ImPrivateMessageDO.builder() - .clientMessageId("uuid-" + System.nanoTime()) - .senderId(senderId) - .receiverId(receiverId) - .type(0) - .content("{\"content\":\"test\"}") - .status(status.getStatus()) - .sendTime(LocalDateTime.now()) - .build(); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java deleted file mode 100644 index c6b1856..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java +++ /dev/null @@ -1,263 +0,0 @@ -package cn.iocoder.yudao.module.im.service.friend; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; -import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.springframework.dao.DuplicateKeyException; - -import java.time.LocalDateTime; -import java.util.List; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * IM 好友关系 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImFriendServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImFriendServiceImpl friendService; - - @Mock - private ImFriendMapper imFriendMapper; - @Mock - private AdminUserApi adminUserApi; - @Mock - private ImWebSocketService imWebSocketService; - @Mock - private ImPrivateMessageService privateMessageService; - - // ========== isFriend ========== - - @Test - public void testIsFriend_enabled() { - ImFriendDO friend = ImFriendDO.builder().userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); - - assertTrue(friendService.isFriend(1L, 2L)); - } - - @Test - public void testIsFriend_disabledOrAbsent() { - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); - assertFalse(friendService.isFriend(1L, 2L)); - - ImFriendDO disabled = ImFriendDO.builder().userId(1L).friendUserId(3L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 3L)).thenReturn(disabled); - assertFalse(friendService.isFriend(1L, 3L)); - } - - // ========== updateFriend ========== - - @Test - public void testUpdateFriend_success() { - // 准备 - ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); - reqVO.setFriendUserId(2L); - reqVO.setMuted(true); - ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) - .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend); - - // 调用 - friendService.updateFriend(1L, reqVO); - - // 断言:更新了 muted 字段 - ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); - verify(imFriendMapper).updateById(captor.capture()); - assertEquals(100L, captor.getValue().getId()); - assertTrue(captor.getValue().getMuted()); - // 断言:推送了好友更新通知 - verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - } - - @Test - public void testUpdateFriend_notFriend() { - // 准备 - ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); - reqVO.setFriendUserId(2L); - reqVO.setMuted(true); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> friendService.updateFriend(1L, reqVO)); - assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); - } - - // ========== addFriend ========== - - @Test - public void testAddFriend_self() { - ServiceException exception = assertThrows(ServiceException.class, - () -> friendService.addFriend(1L, 1L)); - assertEquals(FRIEND_ADD_SELF.getCode(), exception.getCode()); - } - - @Test - public void testAddFriend_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) - .thenReturn(friendService); - - // 准备 - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); - when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(null); - - // 调用 - friendService.addFriend(1L, 2L); - - // 断言:校验对方存在 - verify(adminUserApi).validateUser(2L); - // 断言:双向插入 2 条好友关系 - verify(imFriendMapper, times(2)).insert(any(ImFriendDO.class)); - // 断言:发送提示消息 + 给双方各推送一次好友添加事件 - verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); - verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); - } - } - - @Test - public void testAddFriend0_existingEnabledSkipsUpdate() { - // 准备:已存在且启用,第二步不应触发更新 - ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); - - // 调用 - friendService.addFriend0(1L, 2L); - - // 断言:不插入,也不更新 - verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); - verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); - } - - @Test - public void testAddFriend0_existingDisabledRecovers() { - // 准备:已存在且 DISABLE,应当恢复状态 - ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); - - // 调用 - friendService.addFriend0(1L, 2L); - - // 断言:更新 status 为 ENABLE - ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); - verify(imFriendMapper).updateById(captor.capture()); - assertEquals(10L, captor.getValue().getId()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); - verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); - } - - @Test - public void testAddFriend0_duplicateKeySwallowed() { - // 准备:mapper 抛并发冲突 - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(null); - when(imFriendMapper.insert(any(ImFriendDO.class))) - .thenThrow(new DuplicateKeyException("concurrent insert")); - - // 调用:应被吞掉,不抛异常 - friendService.addFriend0(1L, 2L); - - verify(imFriendMapper).insert(any(ImFriendDO.class)); - } - - // ========== deleteFriend ========== - - @Test - public void testDeleteFriend_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImFriendServiceImpl.class))) - .thenReturn(friendService); - - ImFriendDO friend12 = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - ImFriendDO friend21 = ImFriendDO.builder().id(11L).userId(2L).friendUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(friend12); - when(imFriendMapper.selectByUserIdAndFriendUserId(2L, 1L)).thenReturn(friend21); - - friendService.deleteFriend(1L, 2L); - - // 断言:双向更新为 DISABLE - verify(imFriendMapper, times(2)).updateById(any(ImFriendDO.class)); - // 断言:推送提示消息 + 双方各推送一次好友删除事件 - verify(privateMessageService).sendTipPrivateMessage(eq(1L), eq(2L), anyString()); - verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); - } - } - - @Test - public void testDeleteFriend0_alreadyDisabled() { - // 准备:已经是 DISABLE,不再更新 - ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); - - friendService.deleteFriend0(1L, 2L); - - verify(imFriendMapper, never()).updateById(any(ImFriendDO.class)); - } - - @Test - public void testDeleteFriend0_enabledGetsDisabled() { - ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); - - friendService.deleteFriend0(1L, 2L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImFriendDO.class); - verify(imFriendMapper).updateById(captor.capture()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getDeleteTime()); - } - - // ========== 其它读方法 ========== - - @Test - public void testGetFriendList() { - List list = List.of( - ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .deleteTime(LocalDateTime.now()).build() - ); - when(imFriendMapper.selectListByUserId(1L)).thenReturn(list); - - List result = friendService.getFriendList(1L); - assertEquals(2, result.size()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java deleted file mode 100644 index 37a7250..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java +++ /dev/null @@ -1,330 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.springframework.dao.DuplicateKeyException; - -import java.util.List; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * IM 群成员 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImGroupMemberServiceImpl groupMemberService; - - @Mock - private ImGroupMemberMapper groupMemberMapper; - @Mock - private ImWebSocketService webSocketService; - - // ========== addGroupMember ========== - - @Test - public void testAddGroupMember_newInsert() { - // 准备:成员记录不存在 - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); - - // 调用 - ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); - - // 断言:执行了 insert,返回记录的 status 为 ENABLE - assertNotNull(result); - assertEquals(10L, result.getGroupId()); - assertEquals(1L, result.getUserId()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); - assertNotNull(result.getJoinTime()); - verify(groupMemberMapper).insert(any(ImGroupMemberDO.class)); - } - - @Test - public void testAddGroupMember_existingEnabledReturns() { - // 准备:已存在且 ENABLE,只返回已有记录,不做其它操作 - ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); - when(groupMemberMapper.selectById(50L)).thenReturn(exists); - - ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); - - assertEquals(50L, result.getId()); - verify(groupMemberMapper, never()).insert(any(ImGroupMemberDO.class)); - verify(groupMemberMapper, never()).updateById(any(ImGroupMemberDO.class)); - } - - @Test - public void testAddGroupMember_existingDisabledRecovers() { - // 准备:已存在且 DISABLE,应更新为 ENABLE - ImGroupMemberDO exists = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(exists); - ImGroupMemberDO recovered = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectById(50L)).thenReturn(recovered); - - ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); - verify(groupMemberMapper).updateById(captor.capture()); - assertEquals(50L, captor.getValue().getId()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getJoinTime()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); - } - - @Test - public void testAddGroupMember_duplicateKeyFallsBackToSelect() { - // 准备:第一次 select 返回 null,insert 抛 DuplicateKey,再次 select 返回已插入记录 - ImGroupMemberDO inserted = ImGroupMemberDO.builder().id(80L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null).thenReturn(inserted); - when(groupMemberMapper.insert(any(ImGroupMemberDO.class))) - .thenThrow(new DuplicateKeyException("concurrent insert")); - - // 调用:冲突后降级 select - ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); - - assertNotNull(result); - assertEquals(80L, result.getId()); - } - - // ========== addGroupMembers ========== - - @Test - public void testAddGroupMembers_mixedInsertAndUpdate() { - // 准备:用户 2 不存在(新增),用户 3 已存在且 DISABLE(恢复),用户 4 已存在且 ENABLE(跳过) - ImGroupMemberDO exist3 = ImGroupMemberDO.builder().id(30L).groupId(10L).userId(3L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - ImGroupMemberDO exist4 = ImGroupMemberDO.builder().id(40L).groupId(10L).userId(4L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) - .thenReturn(List.of(exist3, exist4)); - - // 调用 - groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L)); - - // 断言:updates 只有用户 3;inserts 只有用户 2 - verify(groupMemberMapper).updateBatch(argThat((List list) -> - list.size() == 1 && list.get(0).getId().equals(30L) - && CommonStatusEnum.ENABLE.getStatus().equals(list.get(0).getStatus()))); - verify(groupMemberMapper).insertBatch(argThat((List list) -> - list.size() == 1 && list.get(0).getUserId().equals(2L))); - } - - @Test - public void testAddGroupMembers_allExisting_onlyUpdates() { - // 准备:传入的 3 个用户都已有记录(全部 DISABLE) → 只做 update,不做 insert - List existing = List.of( - ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(), - ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L) - .status(CommonStatusEnum.DISABLE.getStatus()).build() - ); - when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) - .thenReturn(existing); - - groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); - - verify(groupMemberMapper).updateBatch(anyList()); - verify(groupMemberMapper, never()).insertBatch(anyList()); - } - - @Test - public void testAddGroupMembers_allNew_onlyInserts() { - // 准备:都不存在 → 只做 insert - when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) - .thenReturn(List.of()); - - groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); - - verify(groupMemberMapper, never()).updateBatch(anyList()); - verify(groupMemberMapper).insertBatch(anyList()); - } - - @Test - public void testAddGroupMembers_allExistingEnabled_nothingHappens() { - // 准备:都已存在且 ENABLE → 既不 update 也不 insert - List existing = List.of( - ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) - .thenReturn(existing); - - groupMemberService.addGroupMembers(10L, List.of(2L)); - - verify(groupMemberMapper, never()).updateBatch(anyList()); - verify(groupMemberMapper, never()).insertBatch(anyList()); - } - - @Test - public void testAddGroupMembers_batchInsertDuplicateFallback() { - // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember - when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) - .thenReturn(List.of()); - doThrow(new DuplicateKeyException("concurrent batch insert")) - .when(groupMemberMapper).insertBatch(anyList()); - // addGroupMember 单条兜底逻辑 - when(groupMemberMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); - - // 调用 - groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); - - // 断言:降级为逐条调用 insert - verify(groupMemberMapper, times(2)).insert(any(ImGroupMemberDO.class)); - } - - // ========== validateMemberInGroup ========== - - @Test - public void testValidateMemberInGroup_notInGroup() { - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMemberService.validateMemberInGroup(10L, 1L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - @Test - public void testValidateMemberInGroup_disabledEqualsNotInGroup() { - ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMemberService.validateMemberInGroup(10L, 1L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - @Test - public void testValidateMemberInGroup_success() { - ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); - - ImGroupMemberDO result = groupMemberService.validateMemberInGroup(10L, 1L); - assertEquals(50L, result.getId()); - } - - // ========== updateGroupMember ========== - - @Test - public void testUpdateGroupMember_success() { - ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); - - ImGroupMemberUpdateReqVO reqVO = new ImGroupMemberUpdateReqVO() - .setGroupId(10L).setMuted(true).setDisplayUserName("昵称"); - - groupMemberService.updateGroupMember(1L, reqVO); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); - verify(groupMemberMapper).updateById(captor.capture()); - assertEquals(50L, captor.getValue().getId()); - assertTrue(captor.getValue().getMuted()); - verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); - } - - // ========== removeGroupMember ========== - - @Test - public void testRemoveGroupMember_success() { - ImGroupMemberDO member = ImGroupMemberDO.builder().id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(member); - - groupMemberService.removeGroupMember(10L, 1L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); - verify(groupMemberMapper).updateById(captor.capture()); - assertEquals(50L, captor.getValue().getId()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getQuitTime()); - } - - @Test - public void testRemoveGroupMember_notInGroup() { - when(groupMemberMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMemberService.removeGroupMember(10L, 1L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - // ========== removeGroupMembers ========== - - @Test - public void testRemoveGroupMembers_batch() { - groupMemberService.removeGroupMembers(10L, List.of(2L, 3L)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); - verify(groupMemberMapper).updateByGroupIdAndUserIdsAndStatus(eq(10L), anyCollection(), - eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getQuitTime()); - } - - @Test - public void testRemoveGroupMembersByGroupId() { - groupMemberService.removeGroupMembersByGroupId(10L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMemberDO.class); - verify(groupMemberMapper).updateByGroupIdAndStatus(eq(10L), - eq(CommonStatusEnum.ENABLE.getStatus()), captor.capture()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getQuitTime()); - } - - // ========== getActiveGroupMemberUserIdsByGroupId ========== - - @Test - public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() { - // 准备:3 个 ENABLE 成员 - List members = List.of( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(3L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberMapper.selectListByGroupIdAndStatus( - 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members); - - // 调用 - List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); - - // 断言:只返回 userId、顺序保留 - assertEquals(List.of(1L, 2L, 3L), userIds); - } - - @Test - public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() { - when(groupMemberMapper.selectListByGroupIdAndStatus( - 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); - - List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); - - assertTrue(userIds.isEmpty()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java deleted file mode 100644 index a68b897..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ /dev/null @@ -1,518 +0,0 @@ -package cn.iocoder.yudao.module.im.service.group; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; -import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; -import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * IM 群 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImGroupServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImGroupServiceImpl groupService; - - @Mock - private ImGroupMapper groupMapper; - @Mock - private ImGroupMemberService groupMemberService; - @Mock - private ImGroupMessageService groupMessageService; - @Mock - private ImWebSocketService webSocketService; - @Mock - private ImFriendService friendService; - @Mock - private AdminUserApi adminUserApi; - - // ========== createGroup ========== - - @Test - public void testCreateGroup_success() { - // 准备 - ImGroupCreateReqVO reqVO = new ImGroupCreateReqVO(); - reqVO.setName("测试群"); - when(groupMapper.insert(any(ImGroupDO.class))).thenAnswer(invocation -> { - ImGroupDO group = invocation.getArgument(0); - group.setId(100L); - return 1; - }); - - // 调用 - ImGroupDO result = groupService.createGroup(reqVO, 1L); - - // 断言:群主 + 状态 - assertEquals(100L, result.getId()); - assertEquals(1L, result.getOwnerUserId()); - assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); - // 验证:群主加入群 + 推送群创建事件 - verify(groupMemberService).addGroupMember(100L, 1L); - verify(webSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); - } - - // ========== updateGroup ========== - - @Test - public void testUpdateGroup_notOwner() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备:当前用户不是群主 - ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(99L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); - reqVO.setId(10L); - reqVO.setName("新名字"); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.updateGroup(reqVO, 1L)); - assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); - } - } - - @Test - public void testUpdateGroup_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备 - ImGroupDO group = ImGroupDO.builder().id(10L).name("旧名字").ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - List members = List.of( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); - - ImGroupUpdateReqVO reqVO = new ImGroupUpdateReqVO(); - reqVO.setId(10L); - reqVO.setName("新名字"); - - // 调用 - ImGroupDO result = groupService.updateGroup(reqVO, 1L); - - // 断言:更新了数据库 - verify(groupMapper).updateById(any(ImGroupDO.class)); - assertEquals("新名字", result.getName()); - // 推送给所有成员 - verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - } - - // ========== dissolveGroup ========== - - @Test - public void testDissolveGroup_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备 - ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - List members = List.of( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); - AdminUserRespDTO user = new AdminUserRespDTO(); - user.setId(1L); - user.setNickname("群主"); - when(adminUserApi.getUser(1L)).thenReturn(user); - - // 调用 - groupService.dissolveGroup(10L, 1L); - - // 断言:群状态变为 DISABLE + 群成员全部移除 + 清理已读缓存 - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupDO.class); - verify(groupMapper).updateById(captor.capture()); - assertEquals(CommonStatusEnum.DISABLE.getStatus(), captor.getValue().getStatus()); - assertNotNull(captor.getValue().getDissolvedTime()); - verify(groupMemberService).removeGroupMembersByGroupId(10L); - verify(groupMessageService).deleteReadMaxMessageIdMap(10L); - // 发送解散提示消息 + 推送群删除事件 - verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); - verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - } - - @Test - public void testDissolveGroup_notOwner() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.dissolveGroup(10L, 1L)); - assertEquals(GROUP_NOT_OWNER.getCode(), exception.getCode()); - } - } - - // ========== inviteGroupMember ========== - - @Test - public void testInviteGroupMember_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备:群存在 + 当前用户是群成员 - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - // 当前成员只有群主 - List activeMembers = new ArrayList<>(); - activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build()); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); - - // 被邀请人 2 和 3 都是好友 - ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); - List friends = List.of( - ImFriendDO.builder().userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImFriendDO.builder().userId(1L).friendUserId(3L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(friends); - - // 用户昵称 - AdminUserRespDTO u1 = new AdminUserRespDTO(); u1.setId(1L); u1.setNickname("群主"); - AdminUserRespDTO u2 = new AdminUserRespDTO(); u2.setId(2L); u2.setNickname("张三"); - AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); - Map userMap23 = new HashMap<>(); - userMap23.put(2L, u2); - userMap23.put(3L, u3); - Map userMap1 = Map.of(1L, u1); - when(adminUserApi.getUserMap(List.of(2L, 3L))).thenReturn(userMap23); - when(adminUserApi.getUserMap(List.of(1L))).thenReturn(userMap1); - - // 调用 - groupService.inviteGroupMember(1L, reqVO); - - // 断言:校验群成员 + 批量添加成员 + 推送事件 + 发送提示消息 - verify(groupMemberService).validateMemberInGroup(10L, 1L); - verify(groupMemberService).addGroupMembers(eq(10L), anyCollection()); - verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); - } - } - - @Test - public void testInviteGroupMember_notFriend() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)) - .thenReturn(List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build())); - - ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(List.of(2L, 3L))); - // 只有 2 是好友,3 不是 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( - ImFriendDO.builder().userId(1L).friendUserId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build())); - AdminUserRespDTO u3 = new AdminUserRespDTO(); u3.setId(3L); u3.setNickname("李四"); - when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(3L, u3)); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.inviteGroupMember(1L, reqVO)); - assertEquals(GROUP_INVITE_NOT_FRIEND.getCode(), exception.getCode()); - } - } - - @Test - public void testInviteGroupMember_memberExceed() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备:群 10 已有 499 人(逼近 MAX_GROUP_MEMBER=500),再邀请 2 人 → 501 超限 - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - List activeMembers = new ArrayList<>(); - for (long i = 1; i <= 499; i++) { - activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(i) - .status(CommonStatusEnum.ENABLE.getStatus()).build()); - } - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); - - ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); - // 被邀请人都是好友 - when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( - ImFriendDO.builder().userId(1L).friendUserId(600L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImFriendDO.builder().userId(1L).friendUserId(601L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - )); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.inviteGroupMember(1L, reqVO)); - assertEquals(GROUP_MEMBER_EXCEED.getCode(), exception.getCode()); - // 断言:不加成员、不推送 - verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); - } - } - - @Test - public void testInviteGroupMember_skipsMembersAlreadyInGroup() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - // 准备 - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - // 用户 2 已在群中 - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(List.of( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - )); - - ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(new ArrayList<>(List.of(2L))); // 只邀请 2,他已在群中 - - // 调用 - groupService.inviteGroupMember(1L, reqVO); - - // 断言:不会触发添加、不会推送 - verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); - verify(webSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - } - - // ========== quitGroup ========== - - @Test - public void testQuitGroup_ownerCannotQuit() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.quitGroup(10L, 1L)); - assertEquals(GROUP_OWNER_CANNOT_QUIT.getCode(), exception.getCode()); - } - } - - @Test - public void testQuitGroup_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - groupService.quitGroup(10L, 1L); - - verify(groupMemberService).removeGroupMember(10L, 1L); - verify(groupMessageService).deleteReadMaxMessageId(10L, 1L); - verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); - verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - } - - // ========== removeGroupMember ========== - - @Test - public void testRemoveGroupMember_cannotRemoveSelf() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(List.of(1L, 2L)); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.removeGroupMember(1L, reqVO)); - assertEquals(GROUP_CANNOT_REMOVE_SELF.getCode(), exception.getCode()); - } - } - - @Test - public void testRemoveGroupMember_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ImGroupMemberRemoveReqVO reqVO = new ImGroupMemberRemoveReqVO(); - reqVO.setGroupId(10L); - reqVO.setMemberUserIds(List.of(2L, 3L)); - - groupService.removeGroupMember(1L, reqVO); - - verify(groupMemberService).removeGroupMembers(eq(10L), anyCollection()); - verify(groupMessageService).deleteReadMaxMessageIds(eq(10L), anyCollection()); - verify(groupMessageService).sendTipGroupMessage(eq(1L), eq(10L), anySet(), anyString()); - verify(webSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - } - - // ========== validateGroupExists ========== - - @Test - public void testValidateGroupExists_notExists() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - when(groupMapper.selectById(10L)).thenReturn(null); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.validateGroupExists(10L)); - assertEquals(GROUP_NOT_EXISTS.getCode(), exception.getCode()); - } - } - - @Test - public void testValidateGroupExists_banned() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L).banned(true) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.validateGroupExists(10L)); - assertEquals(GROUP_BANNED.getCode(), exception.getCode()); - } - } - - @Test - public void testValidateGroupExists_dissolved() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) - .thenReturn(groupService); - - ImGroupDO group = ImGroupDO.builder().id(10L) - .status(CommonStatusEnum.DISABLE.getStatus()).build(); - when(groupMapper.selectById(10L)).thenReturn(group); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupService.validateGroupExists(10L)); - assertEquals(GROUP_DISSOLVED.getCode(), exception.getCode()); - } - } - - // ========== getMyGroupList ========== - - @Test - public void testGetMyGroupList_noMembers() { - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(new ArrayList<>()); - - List result = groupService.getMyGroupList(1L); - assertTrue(result.isEmpty()); - verify(groupMapper, never()).selectByIds(anyCollection()); - } - - @Test - public void testGetMyGroupList_success() { - // 活跃群成员 - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(20L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ))); - // 最近退群成员(最近 30 天内) - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(new ArrayList<>(List.of( - ImGroupMemberDO.builder().groupId(30L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()).build() - ))); - List groups = List.of( - ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMapper.selectByIds(anyCollection())).thenReturn(groups); - - List result = groupService.getMyGroupList(1L); - assertEquals(3, result.size()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java deleted file mode 100644 index 9fa3d9d..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ /dev/null @@ -1,1063 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; -import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; -import cn.iocoder.yudao.module.im.service.group.ImGroupService; -import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; - -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * IM 群聊消息 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImGroupMessageServiceImpl groupMessageService; - - @Mock - private ImGroupMessageMapper groupMessageMapper; - @Mock - private ImGroupService groupService; - @Mock - private ImGroupMemberService groupMemberService; - @Mock - private ImSensitiveWordService sensitiveWordService; - @Mock - private GroupMessageReadRedisDAO groupMessageReadRedisDAO; - @Mock - private ImWebSocketService imWebSocketService; - - private ImGroupMessageSendReqVO buildSendReqVO() { - ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); - reqVO.setClientMessageId("test-uuid-group-001"); - reqVO.setGroupId(10L); - reqVO.setType(0); // TEXT - reqVO.setContent("{\"content\":\"群聊你好\"}"); - return reqVO; - } - - // ========== 发送测试 ========== - - @Test - public void testSendMessage_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) - .thenReturn(groupMessageService); - - // 准备 - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); - when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) - .thenReturn(null); - ImGroupDO group = new ImGroupDO(); - group.setId(10L); - group.setName("测试群"); - when(groupService.validateGroupExists(10L)).thenReturn(group); - - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(List.of(1L, 2L, 3L)); - when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { - ImGroupMessageDO msg = invocation.getArgument(0); - msg.setId(99L); - return 1; - }); - - // 调用 - ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); - - // 断言 - assertNotNull(result); - assertEquals(1L, result.getSenderId()); - assertEquals(10L, result.getGroupId()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); - - // 验证推送给 3 个群成员(含发送者自己,用于多端同步) - verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), - any(ImGroupMessageDTO.class)); - } - } - - @Test - public void testSendMessage_clientMessageIdIdempotent() { - // 准备 - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); - ImGroupMessageDO existing = ImGroupMessageDO.builder() - .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L) - .type(0).content("{\"content\":\"群聊你好\"}").status(0) - .sendTime(LocalDateTime.now()).build(); - when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) - .thenReturn(existing); - - // 调用 - ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); - - // 断言 - assertEquals(100L, result.getId()); - verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); - } - - @Test - public void testSendMessage_notInGroup() { - // 准备 - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); - when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) - .thenReturn(null); - ImGroupDO group = new ImGroupDO(); - group.setId(10L); - when(groupService.validateGroupExists(10L)).thenReturn(group); - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.sendGroupMessage(1L, reqVO)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - // ========== pull 测试 ========== - - @Test - public void testPullMessages_joinTimeFilter() { - // 准备:消息时间用"最近 1 天内"避免被 30 天窗口过滤 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(2); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - // 入群前 1 条 + 入群后 1 条 - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(beforeJoin, afterJoin)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:入群前消息不可见 - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getId()); - } - - @Test - public void testPullMessages_activeQueryRetriesWhenFirstBatchInvisible() { - // 准备:首批 2 条(入群前 + 定向给别人)全部不可见,第二批命中可见消息 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(1); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(2L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(now.minusHours(2)).build(); - ImGroupMessageDO visibleMsg = ImGroupMessageDO.builder() - .id(3L).groupId(10L).senderId(2L) - .sendTime(now.minusMinutes(30)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2))) - .thenReturn(List.of(beforeJoin, directedMsg)); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(2L), - any(LocalDateTime.class), eq(2))) - .thenReturn(List.of(visibleMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); - - // 断言:仅返回第二批的可见消息;两批 selectListByMinId 各被调用一次 - assertEquals(1, result.size()); - assertEquals(3L, result.get(0).getId()); - verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2)); - verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(2L), - any(LocalDateTime.class), eq(2)); - } - - @Test - public void testPullMessages_quitMemberLoadsPreQuitMessages() { - // 准备:当前群(在群)+ 一个最近退群 - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); - - ImGroupMessageDO activeMsg = ImGroupMessageDO.builder() - .id(5L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(2)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(activeMsg)); - - // 已退群:退群时间在窗口内(minId=0,minQuitTime 直接用 minSendTime) - LocalDateTime quitTime = now.minusDays(3); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(20)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of(quitMember)); - - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() - .id(3L).groupId(20L).senderId(99L) - .sendTime(now.minusDays(5)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), - any(LocalDateTime.class), eq(quitTime), eq(100))) - .thenReturn(List.of(quitGroupMsg)); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:两条消息均返回,并按 id 升序 - assertEquals(2, result.size()); - assertEquals(3L, result.get(0).getId()); - assertEquals(5L, result.get(1).getId()); - } - - @Test - public void testPullMessages_quitMemberFilteredByBoundarySendTime() { - // 准备:minId > 0,边界消息 sendTime 晚于窗口起点,应当被用作 minQuitTime - LocalDateTime now = LocalDateTime.now(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); - - LocalDateTime boundarySendTime = now.minusDays(2); - ImGroupMessageDO boundary = ImGroupMessageDO.builder() - .id(8L).groupId(10L).senderId(2L).sendTime(boundarySendTime).build(); - when(groupMessageMapper.selectById(8L)).thenReturn(boundary); - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) - .thenReturn(List.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 8L, 100); - - // 断言:使用边界消息 sendTime 抬升 minQuitTime - assertEquals(0, result.size()); - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); - } - - @Test - public void testPullMessages_quitQueryKeepsOriginalMinIdWhenActiveQueryRetries() { - // 准备:主查询首批不可见触发重试(游标从 8→101),退群补齐仍使用原始 minId=8 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime joinTime = now.minusHours(1); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); - - ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(2L) - .sendTime(now.minusHours(3)).build(); - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(101L).groupId(10L).senderId(2L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(now.minusMinutes(50)).build(); - ImGroupMessageDO visibleActiveMsg = ImGroupMessageDO.builder() - .id(102L).groupId(10L).senderId(2L) - .sendTime(now.minusMinutes(30)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(8L), - any(LocalDateTime.class), eq(2))) - .thenReturn(List.of(beforeJoin, directedMsg)); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(101L), - any(LocalDateTime.class), eq(2))) - .thenReturn(List.of(visibleActiveMsg)); - - LocalDateTime boundarySendTime = now.minusDays(2); - ImGroupMessageDO boundary = ImGroupMessageDO.builder() - .id(8L).groupId(99L).senderId(2L).sendTime(boundarySendTime).build(); - when(groupMessageMapper.selectById(8L)).thenReturn(boundary); - - LocalDateTime quitTime = now.minusHours(12); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(10)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(1L, boundarySendTime)) - .thenReturn(List.of(quitMember)); - - ImGroupMessageDO quitGroupMsg = ImGroupMessageDO.builder() - .id(50L).groupId(20L).senderId(99L) - .sendTime(now.minusDays(1)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), - any(LocalDateTime.class), eq(quitTime), eq(2))) - .thenReturn(List.of(quitGroupMsg)); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 8L, 2); - - // 断言:退群补齐使用原始 minId=8 而非主查询重试后的 101 - assertEquals(2, result.size()); - assertEquals(50L, result.get(0).getId()); - assertEquals(102L, result.get(1).getId()); - verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(8L), - any(LocalDateTime.class), eq(2)); - verify(groupMessageMapper).selectListByMinId(eq(List.of(10L)), eq(101L), - any(LocalDateTime.class), eq(2)); - verify(groupMemberService).getQuitGroupMemberListByUserId(1L, boundarySendTime); - verify(groupMessageMapper).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(8L), - any(LocalDateTime.class), eq(quitTime), eq(2)); - verify(groupMessageMapper, never()).selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(101L), - any(LocalDateTime.class), eq(quitTime), eq(2)); - } - - @Test - public void testPullMessages_receiverUserIdsFilter() { - // 准备 - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - // 定向接收消息:只给用户 2 和 3 - ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(5L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(now.minusHours(2)).build(); - // 全员消息 - ImGroupMessageDO allMsg = ImGroupMessageDO.builder() - .id(2L).groupId(10L).senderId(5L) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(directedMsg, allMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:定向接收的消息用户 1 看不到,只能看到全员消息 - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getId()); - } - - @Test - public void testPullMessages_atFieldCorrectReturn() { - // 准备 - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - ImGroupMessageDO atMsg = ImGroupMessageDO.builder() - .id(1L).groupId(10L).senderId(2L) - .atUserIds(List.of(1L, 3L)) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(atMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // 调用 - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - // 断言:@ 字段正确返回 - assertEquals(1, result.size()); - assertEquals(List.of(1L, 3L), result.get(0).getAtUserIds()); - } - - @Test - public void testPullMessages_pageTrimmedToSize() { - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO activeMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(activeMember)); - - ImGroupMessageDO activeMsg1 = ImGroupMessageDO.builder() - .id(1000L).groupId(10L).senderId(2L).sendTime(now.minusHours(2)).build(); - ImGroupMessageDO activeMsg2 = ImGroupMessageDO.builder() - .id(2000L).groupId(10L).senderId(2L).sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(2))) - .thenReturn(List.of(activeMsg1, activeMsg2)); - - LocalDateTime quitTime = now.minusDays(1); - ImGroupMemberDO quitMember = ImGroupMemberDO.builder() - .groupId(20L).userId(1L) - .status(CommonStatusEnum.DISABLE.getStatus()) - .joinTime(now.minusDays(20)) - .quitTime(quitTime).build(); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of(quitMember)); - - ImGroupMessageDO quit1 = ImGroupMessageDO.builder() - .id(150L).groupId(20L).senderId(99L).sendTime(now.minusDays(5)).build(); - ImGroupMessageDO quit2 = ImGroupMessageDO.builder() - .id(160L).groupId(20L).senderId(99L).sendTime(now.minusDays(4)).build(); - when(groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(eq(20L), eq(0L), - any(LocalDateTime.class), eq(quitTime), eq(2))) - .thenReturn(List.of(quit1, quit2)); - - when(groupMessageReadRedisDAO.getReadMaxMessageId(anyLong(), eq(1L))).thenReturn(null); - - List result = groupMessageService.pullGroupMessageList(1L, 0L, 2); - - assertEquals(2, result.size()); - assertEquals(150L, result.get(0).getId()); - assertEquals(160L, result.get(1).getId()); - } - - @Test - public void testPullMessages_pullSetsReadFromRedisCursor() { - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - ImGroupMessageDO low = ImGroupMessageDO.builder() - .id(5L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(now.minusHours(2)).build(); - ImGroupMessageDO high = ImGroupMessageDO.builder() - .id(10L).groupId(10L).senderId(2L).status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(low, high)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(7L); - - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - assertEquals(2, result.size()); - assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); - } - - @Test - public void testPullMessages_receiptReadCountForSender() { - LocalDateTime now = LocalDateTime.now(); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(10)).build(); - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); - - ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .sendTime(now.minusHours(1)).build(); - when(groupMessageMapper.selectListByMinId(eq(List.of(10L)), eq(0L), - any(LocalDateTime.class), eq(100))) - .thenReturn(List.of(receiptMsg)); - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) - .thenReturn(List.of()); - - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(100L); - Map positions = new HashMap<>(); - positions.put(1L, 100L); - positions.put(2L, 100L); - positions.put(3L, 50L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); - - List allMembers = List.of( - member, - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(20)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(3L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(now.minusDays(20)).build() - ); - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); - - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - - assertEquals(1, result.size()); - assertEquals(1, result.get(0).getReadCount()); - } - - // ========== 撤回测试 ========== - - @Test - public void testRecallMessage_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) - .thenReturn(groupMessageService); - - // 准备:消息由用户 1 发送,刚发送 5 分钟内 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(groupMessageMapper.selectById(50L)).thenReturn(message); - // 撤回前需要校验用户仍在群中 - ImGroupMemberDO selfMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(selfMember); - when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); - when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); - - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(List.of(1L, 2L)); - - // 调用 - ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); - - // 断言:返回 TIP_TEXT 消息 - assertNotNull(result); - // 验证:更新原消息状态 + 插入 tipMessage - verify(groupMessageMapper).updateById(any(ImGroupMessageDO.class)); - verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); - // 验证:给 2 个活跃成员推送撤回提示 - verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 2 && ids.contains(1L) && ids.contains(2L)), - any(ImGroupMessageDTO.class)); - } - } - - @Test - public void testRecallMessage_notOwn() { - // 准备 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(50L).senderId(2L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(groupMessageMapper.selectById(50L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.recallGroupMessage(1L, 50L)); - assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_alreadyRecalled() { - // 准备 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.RECALL.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(groupMessageMapper.selectById(50L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.recallGroupMessage(1L, 50L)); - assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_timeout() { - // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now().minusMinutes(10)).build(); - when(groupMessageMapper.selectById(50L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.recallGroupMessage(1L, 50L)); - assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); - } - - // ========== 群已读测试 ========== - - @Test - public void testReadMessages_notInGroup() { - // 准备:当前用户不在群中 - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); - - // 调用并断言:越权校验 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - @Test - public void testGetReadUsers_withVisibleScope() { - // 准备:用户 1 是群成员 - ImGroupMemberDO currentMember = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(currentMember); - - // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(80L).groupId(10L).senderId(5L) - .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); - when(groupMessageMapper.selectById(80L)).thenReturn(message); - - // 准备:群成员一览 - // 用户 1: 正常,入群在消息之前 - // 用户 2: 正常,入群在消息之前 - // 用户 3: 正常,但入群在消息之后 → 不可见 - // 用户 5: 发送者,不计入回执 - List allMembers = List.of( - currentMember, - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(3L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 4, 13, 0, 0, 0)).build(), // 消息之后才入群 - ImGroupMemberDO.builder().groupId(10L).userId(5L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 - ); - when(groupMemberService.getGroupMemberListByGroupId(10L)).thenReturn(allMembers); - - // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 - Map positions = new HashMap<>(); - positions.put(1L, 100L); - positions.put(2L, 50L); - positions.put(3L, 200L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); - - // 调用:查询 messageId=80 的已读用户 - List readUsers = groupMessageService.getGroupReadUsers(1L, 10L, 80L); - - // 断言: - // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ - // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ - // 用户 3: 入群在消息之后 → 不可见 → 不算 - // 用户 5: 发送者 → 排除 - assertEquals(1, readUsers.size()); - assertTrue(readUsers.contains(1L)); - assertFalse(readUsers.contains(2L)); - assertFalse(readUsers.contains(3L)); - assertFalse(readUsers.contains(5L)); - } - - @Test - public void testGetReadUsers_notInGroup() { - // 准备:当前用户不在群中 - when(groupMemberService.validateMemberInGroup(10L, 99L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); - - // 调用并断言:越权校验 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.getGroupReadUsers(99L, 10L, 80L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - } - - // ========== 发送:补充边界 ========== - - @Test - public void testSendMessage_receiptPending() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) - .thenReturn(groupMessageService); - - // 准备:reqVO.receipt=true - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); - reqVO.setReceipt(true); - when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) - .thenReturn(null); - ImGroupDO group = new ImGroupDO(); - group.setId(10L); - when(groupService.validateGroupExists(10L)).thenReturn(group); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); - when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); - - // 调用 - ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); - - // 断言:receipt=true → 回执状态为 PENDING - assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); - } - } - - @Test - public void testSendMessage_sensitiveWordBlocked() { - // 准备:文本消息命中敏感词 - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); - when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) - .thenReturn(null); - ImGroupDO group = new ImGroupDO(); - group.setId(10L); - when(groupService.validateGroupExists(10L)).thenReturn(group); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build()); - doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) - .when(sensitiveWordService).validateText(reqVO.getContent()); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.sendGroupMessage(1L, reqVO)); - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); - // 断言:不入库、不推送 - verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); - verify(imWebSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); - } - - // ========== 撤回:补充边界 ========== - - @Test - public void testRecallMessage_notExists() { - when(groupMessageMapper.selectById(50L)).thenReturn(null); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.recallGroupMessage(1L, 50L)); - assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_senderNotInGroup() { - // 准备:消息合法、可撤回时间内,但发送人已退群 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(groupMessageMapper.selectById(50L)).thenReturn(message); - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); - - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.recallGroupMessage(1L, 50L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - // 断言:不执行更新、不插 tip - verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); - verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); - } - - // ========== 已读:happy path + 短路 ========== - - @Test - public void testReadGroupMessages_success() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) - .thenReturn(groupMessageService); - - // 准备:用户在群;Redis 游标从 5 前进到 100 - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); - // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 - when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); - - // 调用 - groupMessageService.readGroupMessages(1L, 10L, 100L); - - // 断言:Redis 游标更新 + READ 事件 - verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); - verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); - } - } - - @Test - public void testReadGroupMessages_cursorAlreadyAhead() { - // 准备:已读游标已 >= 目标,直接短路返回 - ImGroupMemberDO member = ImGroupMemberDO.builder() - .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); - - // 调用 - groupMessageService.readGroupMessages(1L, 10L, 100L); - - // 断言:不更新、不推送 - verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); - verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); - } - - // ========== 回执:DONE 状态迁移 ========== - - @Test - public void testReadGroupMessageEvent_receiptDoneTransition() { - // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} - // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE - ImGroupMessageDO pending = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(5L) - .sendTime(LocalDateTime.now().minusMinutes(1)) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); - when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) - .thenReturn(List.of(pending)); - List activeMembers = List.of( - ImGroupMemberDO.builder().groupId(10L).userId(5L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); - Map positions = new HashMap<>(); - positions.put(1L, 100L); - positions.put(2L, 100L); - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); - - // 调用 - groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); - - // 断言:消息回执状态更新为 DONE - ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); - verify(groupMessageMapper).updateById(captor.capture()); - assertEquals(100L, captor.getValue().getId()); - assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); - // 断言:给消息发送方(5)推送 RECEIPT 事件 - verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); - } - - @Test - public void testReadGroupMessageEvent_receiptStaysPendingWhenPartialRead() { - // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB - ImGroupMessageDO pending = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(5L) - .sendTime(LocalDateTime.now().minusMinutes(1)) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); - when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) - .thenReturn(List.of(pending)); - List activeMembers = List.of( - ImGroupMemberDO.builder().groupId(10L).userId(5L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build(), - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(LocalDateTime.now().minusDays(10)).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); - Map positions = new HashMap<>(); - positions.put(1L, 100L); - positions.put(2L, 50L); // 还没读到 100 - when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); - - groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); - - // 断言:回执状态保持 PENDING,不更新 DB - verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); - // 仍然推送 RECEIPT 事件给发送方(携带已读人数) - verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); - } - - // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== - - @Test - public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { - // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(1L) - .sendTime(LocalDateTime.now()).build(); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(List.of(1L, 2L, 3L, 4L)); - - groupMessageService.sendGroupMessageEvent(message); - - verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 4 && ids.contains(1L) && ids.contains(2L) - && ids.contains(3L) && ids.contains(4L)), - any(ImGroupMessageDTO.class)); - } - - @Test - public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { - // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} - // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(1L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(LocalDateTime.now()).build(); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(List.of(1L, 2L, 3L, 4L)); - - groupMessageService.sendGroupMessageEvent(message); - - verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 3 && ids.contains(1L) && ids.contains(2L) - && ids.contains(3L) && !ids.contains(4L)), - any(ImGroupMessageDTO.class)); - } - - @Test - public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { - // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) - // 预期:仅定向用户可见 - ImGroupMessageDO message = ImGroupMessageDO.builder() - .id(100L).groupId(10L).senderId(99L) - .receiverUserIds(List.of(2L, 3L)) - .sendTime(LocalDateTime.now()).build(); - when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) - .thenReturn(List.of(1L, 2L, 3L, 4L)); - - groupMessageService.sendGroupMessageEvent(message); - - verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> - ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), - any(ImGroupMessageDTO.class)); - } - - // ========== sendTipGroupMessage ========== - - @Test - public void testSendTipGroupMessage_success() { - // 准备 - Set targets = Set.of(1L, 2L, 3L); - - // 调用 - groupMessageService.sendTipGroupMessage(1L, 10L, targets, "张三加入了群聊"); - - // 断言:插入一条 TIP_TEXT 提示消息 - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(ImGroupMessageDO.class); - verify(groupMessageMapper).insert(msgCaptor.capture()); - ImGroupMessageDO tip = msgCaptor.getValue(); - assertEquals(1L, tip.getSenderId()); - assertEquals(10L, tip.getGroupId()); - assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); - assertEquals("张三加入了群聊", tip.getContent()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), tip.getReceiptStatus()); - assertNotNull(tip.getClientMessageId()); - assertNotNull(tip.getSendTime()); - // 断言:推送给指定用户集合 - verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); - } - - // ========== getGroupMessageList ========== - - @Test - public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { - // 准备 - LocalDateTime joinTime = LocalDateTime.now().minusDays(5); - ImGroupMemberDO member = ImGroupMemberDO.builder() - .id(50L).groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()) - .joinTime(joinTime).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - - ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); - reqVO.setGroupId(10L); - reqVO.setMaxId(100L); - reqVO.setLimit(20); - - List mockList = List.of( - ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L).build() - ); - when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); - - // 调用 - List result = groupMessageService.getGroupMessageList(1L, reqVO); - - // 断言:使用成员的 joinTime 作为历史消息的下限 - assertEquals(1, result.size()); - verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); - } - - @Test - public void testGetGroupMessageList_notInGroup() { - // 准备:用户不在群中 - ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); - reqVO.setGroupId(10L); - reqVO.setLimit(20); - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), - GROUP_MEMBER_NOT_IN_GROUP.getMsg())); - - // 调用并断言:鉴权失败 - ServiceException exception = assertThrows(ServiceException.class, - () -> groupMessageService.getGroupMessageList(1L, reqVO)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); - // 断言:不查询 DB - verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); - } - - // ========== delete read cursor 委托 ========== - - @Test - public void testDeleteReadMaxMessageId_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageId(10L, 1L); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); - } - - @Test - public void testDeleteReadMaxMessageIds_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageIds(10L, List.of(1L, 2L)); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, List.of(1L, 2L)); - } - - @Test - public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { - groupMessageService.deleteReadMaxMessageIdMap(10L); - - verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java deleted file mode 100644 index ae40b55..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ /dev/null @@ -1,356 +0,0 @@ -package cn.iocoder.yudao.module.im.service.message; - -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; -import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; -import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; -import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; -import cn.iocoder.yudao.module.im.service.friend.ImFriendService; -import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; -import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import java.time.LocalDateTime; -import java.util.List; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * IM 私聊消息 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImPrivateMessageServiceImpl privateMessageService; - - @Mock - private ImPrivateMessageMapper privateMessageMapper; - @Mock - private ImFriendService friendService; - @Mock - private ImSensitiveWordService sensitiveWordService; - @Mock - private ImWebSocketService imWebSocketService; - - private ImPrivateMessageSendReqVO buildSendReqVO() { - ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); - reqVO.setClientMessageId("test-uuid-001"); - reqVO.setReceiverId(2L); - reqVO.setType(0); // TEXT - reqVO.setContent("{\"content\":\"你好\"}"); - return reqVO; - } - - // ========== 发送测试 ========== - - @Test - public void testSendMessage_success() { - // 准备 - ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) - .thenReturn(null); - when(friendService.isFriend(1L, 2L)).thenReturn(true); - when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenAnswer(invocation -> { - ImPrivateMessageDO msg = invocation.getArgument(0); - msg.setId(99L); - return 1; - }); - - // 调用 - ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); - - // 断言 - assertNotNull(result); - assertEquals(1L, result.getSenderId()); - assertEquals(2L, result.getReceiverId()); - assertEquals(0, result.getType()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); - assertNotNull(result.getSendTime()); - - // 验证调用 - verify(friendService).isFriend(1L, 2L); - verify(sensitiveWordService).validateText(reqVO.getContent()); - verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); - // 验证推送给接收方和发送方 - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); - verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - } - - @Test - public void testSendMessage_clientMessageIdIdempotent() { - // 准备:模拟已存在消息 - ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - ImPrivateMessageDO existingMessage = ImPrivateMessageDO.builder() - .id(100L).clientMessageId("test-uuid-001").senderId(1L).receiverId(2L) - .type(0).content("{\"content\":\"你好\"}").status(0) - .sendTime(LocalDateTime.now()).build(); - when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) - .thenReturn(existingMessage); - - // 调用 - ImPrivateMessageDO result = privateMessageService.sendPrivateMessage(1L, reqVO); - - // 断言:返回已存在的消息 - assertEquals(100L, result.getId()); - // 验证不会重复插入 - verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); - } - - @Test - public void testSendMessage_notFriend() { - // 准备 - ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) - .thenReturn(null); - when(friendService.isFriend(1L, 2L)).thenReturn(false); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.sendPrivateMessage(1L, reqVO)); - assertEquals(FRIEND_NOT_FRIEND.getCode(), exception.getCode()); - } - - // ========== pull 测试 ========== - - @Test - public void testPullMessages_success() { - // 准备 - List mockMessages = List.of( - ImPrivateMessageDO.builder().id(1L).senderId(1L).receiverId(2L).build(), - ImPrivateMessageDO.builder().id(2L).senderId(2L).receiverId(1L).build() - ); - when(privateMessageMapper.selectListByMinId(eq(1L), eq(0L), any(LocalDateTime.class), eq(100))) - .thenReturn(mockMessages); - - // 调用 - List result = privateMessageService.pullPrivateMessageList(1L, 0L, 100); - - // 断言 - assertEquals(2, result.size()); - } - - @Test - public void testPullMessages_sizeExceeded() { - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.pullPrivateMessageList(1L, 0L, 1001)); - assertEquals(MESSAGE_PULL_SIZE_EXCEEDED.getCode(), exception.getCode()); - } - - // ========== 已读测试 ========== - - @Test - public void testReadMessages_success() { - // 准备:mock 未读消息(id=1 和 id=5,maxReadId 应为 5) - List unreadMessages = List.of( - ImPrivateMessageDO.builder().id(1L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(), - ImPrivateMessageDO.builder().id(5L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build() - ); - when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(unreadMessages); - - // 调用 - privateMessageService.readPrivateMessages(1L, 2L); - - // 断言:更新了消息状态 - verify(privateMessageMapper).updateByIdsAndStatus(eq(List.of(1L, 5L)), - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); - - // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); - ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(ImPrivateMessageDTO.class); - verify(imWebSocketService, times(2)).sendPrivateMessageAsync( - userCaptor.capture(), contentCaptor.capture()); - - // 第一次:发给自己的 READ 事件 - assertEquals(1L, userCaptor.getAllValues().get(0)); - ImPrivateMessageDTO readPayload = contentCaptor.getAllValues().get(0); - assertEquals(ImMessageTypeEnum.READ.getType(), readPayload.getType()); - assertEquals(1L, readPayload.getSenderId()); - assertEquals(2L, readPayload.getReceiverId()); - assertEquals(5L, readPayload.getId(), "READ id 应为 maxReadId"); - - // 第二次:发给对方的 RECEIPT 事件 - assertEquals(2L, userCaptor.getAllValues().get(1)); - ImPrivateMessageDTO receiptPayload = contentCaptor.getAllValues().get(1); - assertEquals(ImMessageTypeEnum.RECEIPT.getType(), receiptPayload.getType()); - assertEquals(5L, receiptPayload.getId(), "RECEIPT id 应为 maxReadId"); - } - - // ========== 撤回测试 ========== - - @Test - public void testRecallMessage_success() { - // 准备 - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 - when(privateMessageMapper.selectById(10L)).thenReturn(message); - when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); - when(privateMessageMapper.insert(any(ImPrivateMessageDO.class))).thenReturn(1); - - // 调用 - ImPrivateMessageDO result = privateMessageService.recallPrivateMessage(1L, 10L); - - // 断言:返回 TIP_TEXT 消息 - assertNotNull(result); - // 验证:更新原消息状态 + 插入 tipMessage - verify(privateMessageMapper).updateById(any(ImPrivateMessageDO.class)); - verify(privateMessageMapper).insert(any(ImPrivateMessageDO.class)); - // 验证推送了消息(给接收方和发送方) - verify(imWebSocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); - } - - @Test - public void testRecallMessage_notOwn() { - // 准备 - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(privateMessageMapper.selectById(10L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.recallPrivateMessage(1L, 10L)); - assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_alreadyRecalled() { - // 准备 - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.RECALL.getStatus()) - .sendTime(LocalDateTime.now()).build(); - when(privateMessageMapper.selectById(10L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.recallPrivateMessage(1L, 10L)); - assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_notExists() { - // 准备 - when(privateMessageMapper.selectById(10L)).thenReturn(null); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.recallPrivateMessage(1L, 10L)); - assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); - } - - @Test - public void testRecallMessage_timeout() { - // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) - ImPrivateMessageDO message = ImPrivateMessageDO.builder() - .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .sendTime(LocalDateTime.now().minusMinutes(10)).build(); - when(privateMessageMapper.selectById(10L)).thenReturn(message); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.recallPrivateMessage(1L, 10L)); - assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); - // 断言:不推送、不插 tipMessage - verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); - } - - @Test - public void testSendMessage_sensitiveWordBlocked() { - // 准备:文本消息命中敏感词 - ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); - when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) - .thenReturn(null); - when(friendService.isFriend(1L, 2L)).thenReturn(true); - doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) - .when(sensitiveWordService).validateText(reqVO.getContent()); - - // 调用并断言 - ServiceException exception = assertThrows(ServiceException.class, - () -> privateMessageService.sendPrivateMessage(1L, reqVO)); - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); - // 断言:不入库、不推送 - verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); - verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); - } - - @Test - public void testReadMessages_noUnread() { - // 准备:没有未读消息 - when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, - ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(List.of()); - - // 调用 - privateMessageService.readPrivateMessages(1L, 2L); - - // 断言:不更新、不推送 - verify(privateMessageMapper, never()).updateByIdsAndStatus(anyList(), anyInt(), any(ImPrivateMessageDO.class)); - verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); - } - - // ========== sendTipPrivateMessage ========== - - @Test - public void testSendTipPrivateMessage_success() { - // 调用 - privateMessageService.sendTipPrivateMessage(1L, 2L, "好友添加成功"); - - // 断言:插入一条 TIP_TEXT 提示消息 - ArgumentCaptor captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); - verify(privateMessageMapper).insert(captor.capture()); - ImPrivateMessageDO tip = captor.getValue(); - assertEquals(1L, tip.getSenderId()); - assertEquals(2L, tip.getReceiverId()); - assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); - assertEquals("好友添加成功", tip.getContent()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); - assertNotNull(tip.getClientMessageId()); - assertNotNull(tip.getSendTime()); - // 断言:给双方各推送一次 - verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); - verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); - } - - // ========== getPrivateMessageList ========== - - @Test - public void testGetPrivateMessageList_delegatesToMapper() { - // 准备 - ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO(); - reqVO.setReceiverId(2L); - reqVO.setMaxId(100L); - reqVO.setLimit(20); - List mockList = List.of( - ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(), - ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build() - ); - when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList); - - // 调用 - List result = privateMessageService.getPrivateMessageList(1L, reqVO); - - // 断言:透传到 mapper,参数一致 - assertEquals(2, result.size()); - verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java deleted file mode 100644 index 49b264b..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package cn.iocoder.yudao.module.im.service.sensitiveword; - -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; -import cn.iocoder.yudao.framework.common.exception.ServiceException; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; -import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import java.util.List; - -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -/** - * IM 敏感词 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImSensitiveWordServiceImpl sensitiveWordService; - - @Mock - private ImSensitiveWordMapper imSensitiveWordMapper; - - @BeforeEach - public void setUp() { - // 模拟 @PostConstruct 初始化:加载 2 个启用的敏感词 - when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) - .thenReturn(List.of( - ImSensitiveWordDO.builder().id(1L).word("badword") - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImSensitiveWordDO.builder().id(2L).word("违禁词") - .status(CommonStatusEnum.ENABLE.getStatus()).build() - )); - sensitiveWordService.init(); - } - - @Test - public void testValidateText_null() { - // null 直接返回,不抛异常 - assertDoesNotThrow(() -> sensitiveWordService.validateText(null)); - } - - @Test - public void testValidateText_empty() { - assertDoesNotThrow(() -> sensitiveWordService.validateText("")); - } - - @Test - public void testValidateText_clean() { - // 正常文本不应命中 - assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); - } - - @Test - public void testValidateText_hitEnglish() { - ServiceException exception = assertThrows(ServiceException.class, - () -> sensitiveWordService.validateText("this contains badword here")); - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); - } - - @Test - public void testValidateText_hitChinese() { - ServiceException exception = assertThrows(ServiceException.class, - () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); - } - - @Test - public void testRefresh_reloadsFromMapper() { - // 刷新前:init 调用 mapper 一次 - verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); - - // 准备:新列表不含原词 - when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) - .thenReturn(List.of( - ImSensitiveWordDO.builder().id(3L).word("new").status(CommonStatusEnum.ENABLE.getStatus()).build() - )); - - // 调用:刷新 - sensitiveWordService.refresh(); - - // 断言:原敏感词不再命中,新词命中 - assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword")); - assertThrows(ServiceException.class, - () -> sensitiveWordService.validateText("contains new")); - verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java deleted file mode 100644 index aa42d35..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package cn.iocoder.yudao.module.im.service.websocket; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; -import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; -import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -/** - * IM WebSocket 推送 Service 单元测试 - * - * @author 芋道源码 - */ -public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { - - @InjectMocks - private ImWebSocketServiceImpl imWebSocketService; - - @Mock - private WebSocketMessageSender webSocketMessageSender; - - @AfterEach - public void tearDown() { - // 清理事务同步上下文,避免串扰其它用例 - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } - - // ========== sendPrivateMessageAsync ========== - - @Test - public void testSendPrivateMessageAsync_noTransactionSendsImmediately() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - // 准备 - ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); - - // 调用:无事务,应立即发送 - imWebSocketService.sendPrivateMessageAsync(2L, dto); - - // 断言 - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); - } - } - - @Test - public void testSendPrivateMessageAsync_inTransactionDeferredUntilCommit() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - // 准备:开启事务同步 - TransactionSynchronizationManager.initSynchronization(); - try { - ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); - - // 调用 - imWebSocketService.sendPrivateMessageAsync(2L, dto); - - // 断言:事务未提交,未推送 - verify(webSocketMessageSender, never()).sendObject(anyInt(), anyLong(), anyString(), any()); - - // 模拟事务提交 - List syncs = - TransactionSynchronizationManager.getSynchronizations(); - assertEquals(1, syncs.size()); - syncs.forEach(TransactionSynchronization::afterCommit); - - // 断言:提交后推送 - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImPrivateMessageDTO.TYPE), eq(dto)); - } finally { - TransactionSynchronizationManager.clear(); - } - } - } - - // ========== sendGroupMessageAsync ========== - - @Test - public void testSendGroupMessageAsync_fanOutToAllUsers() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - ImGroupMessageDTO dto = new ImGroupMessageDTO(); - dto.setGroupId(10L); - dto.setSenderId(1L); - - imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); - - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto)); - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto)); - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(3L), eq(ImGroupMessageDTO.TYPE), eq(dto)); - } - } - - @Test - public void testSendGroupMessageAsync_senderExceptionDoesNotBreakOthers() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - ImGroupMessageDTO dto = new ImGroupMessageDTO(); - dto.setGroupId(10L); - // 给 1 号用户推送时抛异常,不能影响 2/3 号 - doThrow(new RuntimeException("user offline")) - .when(webSocketMessageSender).sendObject(anyInt(), eq(1L), anyString(), any()); - - imWebSocketService.sendGroupMessageAsync(List.of(1L, 2L, 3L), dto); - - // 2L 和 3L 也都被推送 - verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); - verify(webSocketMessageSender).sendObject(anyInt(), eq(3L), anyString(), any()); - } - } - - @Test - public void testSendPrivateMessageAsync_exceptionSwallowed() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - // 准备:sender 抛异常 - ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); - doThrow(new RuntimeException("user offline")) - .when(webSocketMessageSender).sendObject(anyInt(), anyLong(), anyString(), any()); - - // 调用:异常应被吞掉,不向上抛 - imWebSocketService.sendPrivateMessageAsync(2L, dto); - - verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); - } - } - - @Test - public void testSendGroupMessageAsync_singleUserDefaultOverload() { - try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { - springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) - .thenReturn(imWebSocketService); - - ImGroupMessageDTO dto = new ImGroupMessageDTO(); - dto.setGroupId(10L); - - imWebSocketService.sendGroupMessageAsync(42L, dto); - - verify(webSocketMessageSender).sendObject( - eq(UserTypeEnum.ADMIN.getValue()), eq(42L), eq(ImGroupMessageDTO.TYPE), eq(dto)); - } - } - -} diff --git a/yudao-module-im/yudao-module-im-biz/src/test/resources/application-unit-test.yaml b/yudao-module-im/yudao-module-im-biz/src/test/resources/application-unit-test.yaml deleted file mode 100644 index b7bb631..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/resources/application-unit-test.yaml +++ /dev/null @@ -1,49 +0,0 @@ -spring: - main: - lazy-initialization: true # 开启懒加载,加快速度 - banner-mode: off # 单元测试,禁用 Banner - ---- #################### 数据库相关配置 #################### - -spring: - # 数据源配置项 - datasource: - name: ruoyi-vue-pro - url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 - driver-class-name: org.h2.Driver - username: sa - password: - druid: - async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 - initial-size: 1 # 单元测试,配置为 1,提升启动速度 - sql: - init: - schema-locations: classpath:/sql/create_tables.sql - encoding: UTF-8 - - # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 - data: - redis: - host: 127.0.0.1 # 地址 - port: 16379 # 端口(单元测试,使用 16379 端口) - database: 0 # 数据库索引 - -mybatis: - lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 - ---- #################### 定时任务相关配置 #################### - ---- #################### 配置中心相关配置 #################### - ---- #################### 服务保障相关配置 #################### - -# Lock4j 配置项(单元测试,禁用 Lock4j) - ---- #################### 监控相关配置 #################### - ---- #################### 芋道相关配置 #################### - -# 芋道配置项,设置当前项目所有自定义的配置 -yudao: - info: - base-package: cn.iocoder.yudao.module diff --git a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql deleted file mode 100644 index 9101fff..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/clean.sql +++ /dev/null @@ -1,5 +0,0 @@ -DELETE FROM "im_private_message"; -DELETE FROM "im_group_message"; -DELETE FROM "im_group"; -DELETE FROM "im_group_member"; -DELETE FROM "im_friend"; diff --git a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql b/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql deleted file mode 100644 index 924c93c..0000000 --- a/yudao-module-im/yudao-module-im-biz/src/test/resources/sql/create_tables.sql +++ /dev/null @@ -1,94 +0,0 @@ -CREATE TABLE IF NOT EXISTS "im_private_message" ( - "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', - "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', - "sender_id" bigint NOT NULL COMMENT '发送人编号', - "receiver_id" bigint NOT NULL COMMENT '接收人编号', - "type" tinyint NOT NULL COMMENT '消息类型', - "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', - "status" tinyint NOT NULL COMMENT '消息状态', - "send_time" timestamp NOT NULL COMMENT '发送时间', - "creator" varchar(64) DEFAULT '', - "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updater" varchar(64) DEFAULT '', - "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted" bit NOT NULL DEFAULT FALSE, - "tenant_id" bigint NOT NULL DEFAULT 0, - PRIMARY KEY ("id") -) COMMENT 'IM 私聊消息表'; - -CREATE TABLE IF NOT EXISTS "im_group_message" ( - "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', - "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', - "sender_id" bigint NOT NULL COMMENT '发送人编号', - "group_id" bigint NOT NULL COMMENT '群编号', - "type" tinyint NOT NULL COMMENT '消息类型', - "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', - "status" tinyint NOT NULL COMMENT '消息状态', - "send_time" timestamp NOT NULL COMMENT '发送时间', - "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '定向接收用户编号列表', - "at_user_ids" varchar(1024) DEFAULT NULL COMMENT '@ 目标用户编号列表', - "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态', - "creator" varchar(64) DEFAULT '', - "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updater" varchar(64) DEFAULT '', - "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted" bit NOT NULL DEFAULT FALSE, - "tenant_id" bigint NOT NULL DEFAULT 0, - PRIMARY KEY ("id") -) COMMENT 'IM 群聊消息表'; - -CREATE TABLE IF NOT EXISTS "im_group" ( - "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', - "name" varchar(64) NOT NULL COMMENT '群名称', - "owner_user_id" bigint NOT NULL COMMENT '群主用户编号', - "avatar" varchar(512) DEFAULT NULL COMMENT '群头像', - "notice" varchar(2048) DEFAULT NULL COMMENT '群公告', - "banned" bit DEFAULT FALSE COMMENT '是否封禁', - "banned_reason" varchar(512) DEFAULT NULL COMMENT '封禁原因', - "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', - "status" tinyint NOT NULL COMMENT '群状态', - "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', - "creator" varchar(64) DEFAULT '', - "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updater" varchar(64) DEFAULT '', - "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted" bit NOT NULL DEFAULT FALSE, - "tenant_id" bigint NOT NULL DEFAULT 0, - PRIMARY KEY ("id") -) COMMENT 'IM 群信息表'; - -CREATE TABLE IF NOT EXISTS "im_group_member" ( - "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', - "group_id" bigint NOT NULL COMMENT '群编号', - "user_id" bigint NOT NULL COMMENT '用户编号', - "display_user_name" varchar(64) DEFAULT NULL COMMENT '组内显示名', - "display_group_name" varchar(64) DEFAULT NULL COMMENT '群显示备注', - "muted" bit DEFAULT FALSE COMMENT '是否免打扰', - "status" tinyint NOT NULL COMMENT '成员状态', - "join_time" timestamp DEFAULT NULL COMMENT '入群时间', - "quit_time" timestamp DEFAULT NULL COMMENT '退群时间', - "creator" varchar(64) DEFAULT '', - "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updater" varchar(64) DEFAULT '', - "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted" bit NOT NULL DEFAULT FALSE, - "tenant_id" bigint NOT NULL DEFAULT 0, - PRIMARY KEY ("id") -) COMMENT 'IM 群成员表'; - -CREATE TABLE IF NOT EXISTS "im_friend" ( - "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', - "user_id" bigint NOT NULL COMMENT '用户编号', - "friend_user_id" bigint NOT NULL COMMENT '好友用户编号', - "muted" bit DEFAULT FALSE COMMENT '是否免打扰', - "status" tinyint NOT NULL COMMENT '好友状态', - "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间', - "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间', - "creator" varchar(64) DEFAULT '', - "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updater" varchar(64) DEFAULT '', - "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted" bit NOT NULL DEFAULT FALSE, - "tenant_id" bigint NOT NULL DEFAULT 0, - PRIMARY KEY ("id") -) COMMENT 'IM 好友关系表'; diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index 1388a58..f6a0b8d 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -125,7 +125,7 @@ cn.iocoder.boot - yudao-module-im-biz + yudao-module-im ${revision} -- libgit2 0.21.4