package cn.iocoder.yudao.module.im.dal.mysql.statistics; 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.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.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; 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.ImContentTypeEnum; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import javax.sql.DataSource; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; /** * {@link ImStatisticsManagerMapper} 的单元测试 * * @author 芋道源码 */ public class ImStatisticsManagerMapperTest extends BaseDbUnitTest { private static final LocalDateTime WINDOW_BEGIN = LocalDateTime.of(2026, 1, 1, 0, 0); private static final LocalDateTime WINDOW_END = LocalDateTime.of(2026, 2, 1, 0, 0); @Resource private ImStatisticsManagerMapper mapper; @Resource private ImPrivateMessageMapper privateMessageMapper; @Resource private ImGroupMessageMapper groupMessageMapper; @Resource private ImGroupMapper groupMapper; @Resource private ImGroupMemberMapper groupMemberMapper; @Resource private DataSource dataSource; private JdbcTemplate jdbcTemplate; @BeforeEach public void initJdbcTemplate() { jdbcTemplate = new JdbcTemplate(dataSource); } // ========== 用户 ========== @Test public void testSelectTotalUserCount() { // 准备:3 个未删除用户 insertUser(1L, LocalDateTime.now()); insertUser(2L, LocalDateTime.now()); insertUser(3L, LocalDateTime.now()); // 调用 + 断言 assertEquals(3L, mapper.selectTotalUserCount()); } @Test public void testSelectNewUserCount_windowFilter() { // 准备:1 个在窗口内 + 2 个在窗口外 insertUser(1L, WINDOW_BEGIN.plusDays(3)); insertUser(2L, WINDOW_BEGIN.minusDays(1)); insertUser(3L, WINDOW_END.plusDays(1)); // 调用 + 断言:只命中窗口内 assertEquals(1L, mapper.selectNewUserCount(WINDOW_BEGIN, WINDOW_END)); } @Test public void testSelectActiveUserCount_distinctAcrossPrivateAndGroup() { // 准备:1 私聊;2 群聊;3 在窗口外;1 既发私聊又发群聊(去重) privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(2))); groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(3))); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(4))); // 用户 1 已计一次 privateMessageMapper.insert(buildPrivate(3L, 4L, WINDOW_BEGIN.minusDays(1))); // 窗口外 // 调用 + 断言:去重后 = 2 assertEquals(2L, mapper.selectActiveUserCount(WINDOW_BEGIN, WINDOW_END)); } @Test public void testSelectNewUserDailyCount_groupByDay() { // 准备:第 1 天 2 个;第 2 天 1 个;窗口外 1 个 insertUser(1L, WINDOW_BEGIN.plusDays(1)); insertUser(2L, WINDOW_BEGIN.plusDays(1)); insertUser(3L, WINDOW_BEGIN.plusDays(2)); insertUser(4L, WINDOW_BEGIN.minusDays(1)); // 调用 List> list = mapper.selectNewUserDailyCount(WINDOW_BEGIN, WINDOW_END); // 断言:返回两个分组、总数 = 3 assertEquals(2, list.size()); long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); assertEquals(3L, sum); } @Test public void testSelectActiveUserDailyCount_distinctPerDay() { // 准备:同一天里用户 1 在私聊与群聊都发了;第二天用户 2 发了 privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(2))); // 调用 List> list = mapper.selectActiveUserDailyCount(WINDOW_BEGIN, WINDOW_END); // 断言:两天合计 2 名活跃用户(同一天 user 1 去重 + 第二天 user 2) assertEquals(2, list.size()); long sum = list.stream().mapToLong(m -> ((Number) m.get("count")).longValue()).sum(); assertEquals(2L, sum); } // ========== 群 ========== @Test public void testSelectTotalGroupCount_onlyEnabled() { // 准备:2 个正常 + 1 个已解散 groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); groupMapper.insert(buildGroup(CommonStatusEnum.DISABLE)); // 调用 + 断言 assertEquals(2L, mapper.selectTotalGroupCount()); } @Test public void testSelectNewGroupCount_windowFilter() { // 准备:1 个窗口内 + 1 个窗口外 groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); groupMapper.insert(buildGroup(CommonStatusEnum.ENABLE)); // 窗口窄到只覆盖 1 秒 LocalDateTime narrowBegin = LocalDateTime.now().plusYears(10); LocalDateTime narrowEnd = narrowBegin.plusSeconds(1); // 调用 + 断言:极窄窗口不命中任何群 assertEquals(0L, mapper.selectNewGroupCount(narrowBegin, narrowEnd)); } @Test public void testSelectGroupSizeDistribution_bucketing() { // 准备:一个 5 人群、一个 15 人群 Long groupSmall = insertGroupAndMembers(5); Long groupMid = insertGroupAndMembers(15); assertNotNull(groupSmall); assertNotNull(groupMid); // 调用 List> dist = mapper.selectGroupSizeDistribution(); // 断言:分桶包含「1-9 人」和「10-49 人」 Map byRange = dist.stream().collect(java.util.stream.Collectors.toMap( m -> m.get("range"), m -> ((Number) m.get("count")).longValue())); assertEquals(1L, byRange.get("1-9 人")); assertEquals(1L, byRange.get("10-49 人")); } // ========== 消息 ========== @Test public void testSelectPrivateMessageCount_windowFilter() { // 准备:1 在窗口内 + 1 在窗口外 privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1))); privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.minusDays(1))); // 调用 + 断言 assertEquals(1L, mapper.selectPrivateMessageCount(WINDOW_BEGIN, WINDOW_END)); } @Test public void testSelectGroupMessageCount_windowFilter() { // 准备:1 在窗口内 + 1 在窗口外 groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.minusDays(1))); // 调用 + 断言 assertEquals(1L, mapper.selectGroupMessageCount(WINDOW_BEGIN, WINDOW_END)); } @Test public void testSelectMessageTypeDistribution_mergePrivateAndGroup() { // 准备:私聊 type=0 ×1,群聊 type=0 ×2、type=1 ×1 privateMessageMapper.insert(buildPrivate(1L, 2L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType())); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType())); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.TEXT.getType())); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1)).setType(ImContentTypeEnum.IMAGE.getType())); // 调用 List> dist = mapper.selectMessageTypeDistribution(WINDOW_BEGIN, WINDOW_END); // 断言:type=TEXT(0) → 3;type=IMAGE(1) → 1 Map byType = dist.stream().collect(java.util.stream.Collectors.toMap( m -> ((Number) m.get("type")).intValue(), m -> ((Number) m.get("count")).longValue())); assertEquals(3L, byType.get(ImContentTypeEnum.TEXT.getType())); assertEquals(1L, byType.get(ImContentTypeEnum.IMAGE.getType())); } @Test public void testSelectTopSenders_orderByCountDescAndLimit() { // 准备:user 1 共 3 条;user 2 共 2 条;user 3 共 1 条 privateMessageMapper.insert(buildPrivate(1L, 9L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(1L, 100L, WINDOW_BEGIN.plusDays(1))); privateMessageMapper.insert(buildPrivate(2L, 9L, WINDOW_BEGIN.plusDays(1))); groupMessageMapper.insert(buildGroupMessage(2L, 100L, WINDOW_BEGIN.plusDays(1))); privateMessageMapper.insert(buildPrivate(3L, 9L, WINDOW_BEGIN.plusDays(1))); // 调用:取 TOP 2 List> tops = mapper.selectTopSenders(WINDOW_BEGIN, WINDOW_END, 2); // 断言:返回 2 条,按消息数倒序,user1 > user2 assertEquals(2, tops.size()); assertEquals(1L, ((Number) tops.get(0).get("userId")).longValue()); assertEquals(3L, ((Number) tops.get(0).get("messageCount")).longValue()); assertEquals(2L, ((Number) tops.get(1).get("userId")).longValue()); assertEquals(2L, ((Number) tops.get(1).get("messageCount")).longValue()); } // ========== 工具方法 ========== private void insertUser(Long id, LocalDateTime createTime) { jdbcTemplate.update( "INSERT INTO system_users (id, username, password, nickname, status, create_time, update_time, deleted, tenant_id) " + "VALUES (?, ?, '', ?, 0, ?, ?, FALSE, 0)", id, "u" + id, "n" + id, createTime, createTime); } private static ImPrivateMessageDO buildPrivate(Long senderId, Long receiverId, LocalDateTime sendTime) { return ImPrivateMessageDO.builder() .clientMessageId("uuid-" + System.nanoTime()) .senderId(senderId).receiverId(receiverId) .type(ImContentTypeEnum.TEXT.getType()) .content("{}") .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(sendTime).build(); } private static ImGroupMessageDO buildGroupMessage(Long senderId, Long groupId, LocalDateTime sendTime) { return new ImGroupMessageDO() .setClientMessageId("uuid-" + System.nanoTime()) .setSenderId(senderId).setGroupId(groupId) .setType(ImContentTypeEnum.TEXT.getType()) .setContent("{}") .setStatus(ImMessageStatusEnum.NORMAL.getStatus()) .setSendTime(sendTime); } private static ImGroupDO buildGroup(CommonStatusEnum status) { return ImGroupDO.builder().name("g" + System.nanoTime()).ownerUserId(1L) .status(status.getStatus()).build(); } private Long insertGroupAndMembers(int memberCount) { ImGroupDO group = buildGroup(CommonStatusEnum.ENABLE); groupMapper.insert(group); for (int i = 0; i < memberCount; i++) { groupMemberMapper.insert(new ImGroupMemberDO() .setGroupId(group.getId()).setUserId((long) (1000 + i)) .setStatus(CommonStatusEnum.ENABLE.getStatus()).setRole(3)); } return group.getId(); } }