From 73d5a1376228031b0a5a9698bb60fc6d1d94b798 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Tue, 3 Nov 2026 19:08:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E8=81=8A=E5=A4=A9=E5=90=8E=E5=8F=B0?=
=?UTF-8?q?=E6=92=A4=E5=9B=9E=E6=B6=88=E6=81=AF=20fix:=E8=81=8A=E5=A4=A9?=
=?UTF-8?q?=E7=A6=81=E7=94=A8bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20261103_private_conversation_index.sql | 18 +
db/im-platform.sql | 5 +-
im-platform/pom.xml | 7 +-
...irdPrivateMessageModerationController.java | 27 ++
.../third/ThirdPrivateMsgController.java | 9 +
.../controller/third/ThirdUserController.java | 5 +-
.../ThirdPrivateConversationDTO.java | 36 ++
.../ThirdPrivateMessageModerateDTO.java | 20 +
.../user/ThirdCreateOrUpdateUserDTO.java | 6 +-
.../listener/PrivateMessageListener.java | 17 +-
.../mapper/PrivateMessageMapper.java | 15 +
.../PrivateMessageModerationService.java | 198 +++++++++
.../impl/PrivateMessageServiceImpl.java | 72 ++--
.../service/third/ThirdPrivateMsgService.java | 4 +
.../impl/ThirdPrivateMsgServiceImpl.java | 74 +++-
.../third/impl/ThirdUserServiceImpl.java | 63 ++-
.../util/PrivateMessageSessionControl.java | 54 +++
.../ThirdPrivateConversationVO.java | 28 ++
.../ThirdPrivateMessageModerateVO.java | 13 +
.../resources/mapper/PrivateMessageMapper.xml | 125 ++++++
...hirdPrivateConversationControllerTest.java | 134 ++++++
...rivateMessageModerationControllerTest.java | 109 +++++
.../third/ThirdUserControllerTest.java | 151 +++++++
.../PrivateConversationMapperMySqlTest.java | 391 ++++++++++++++++++
...PrivateMessageSessionControlMySqlTest.java | 172 ++++++++
.../PrivateMessageModerationMySqlTest.java | 202 +++++++++
.../PrivateMessageModerationReadPathTest.java | 131 ++++++
.../PrivateMessageModerationServiceTest.java | 257 ++++++++++++
...vateMessageSessionControlReadPathTest.java | 177 ++++++++
.../ThirdPrivateConversationServiceTest.java | 129 ++++++
...ThirdPrivateSessionControlServiceTest.java | 150 +++++++
.../third/impl/ThirdUserServiceImplTest.java | 217 ++++++++++
scripts/test-moderation-client.cjs | 140 +++++++
33 files changed, 3101 insertions(+), 55 deletions(-)
create mode 100644 db/20261103_private_conversation_index.sql
create mode 100644 im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationController.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateConversationDTO.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateMessageModerateDTO.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/service/PrivateMessageModerationService.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/util/PrivateMessageSessionControl.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateConversationVO.java
create mode 100644 im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateMessageModerateVO.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateConversationControllerTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationControllerTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/controller/third/ThirdUserControllerTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/mapper/PrivateConversationMapperMySqlTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/mapper/PrivateMessageSessionControlMySqlTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationMySqlTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationReadPathTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationServiceTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/PrivateMessageSessionControlReadPathTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateConversationServiceTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateSessionControlServiceTest.java
create mode 100644 im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdUserServiceImplTest.java
create mode 100644 scripts/test-moderation-client.cjs
diff --git a/db/20261103_private_conversation_index.sql b/db/20261103_private_conversation_index.sql
new file mode 100644
index 0000000..c2a53a1
--- /dev/null
+++ b/db/20261103_private_conversation_index.sql
@@ -0,0 +1,18 @@
+-- MySQL 5.7 / 8.0; run against the IM database during an appropriate maintenance window.
+-- Supports bilateral message history and candidate scans. The all-conversation
+-- grouping still scans messages; review EXPLAIN with representative data.
+-- This script is idempotent and does not change message rows.
+SET @private_conversation_index_exists = (
+ SELECT COUNT(*) FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'im_private_message'
+ AND index_name = 'idx_private_pair_time'
+);
+SET @private_conversation_index_sql = IF(
+ @private_conversation_index_exists = 0,
+ 'ALTER TABLE im_private_message ADD INDEX idx_private_pair_time (send_id, recv_id, send_time, id), ALGORITHM=INPLACE, LOCK=NONE',
+ 'SELECT ''idx_private_pair_time already exists'' AS message'
+);
+PREPARE private_conversation_index_stmt FROM @private_conversation_index_sql;
+EXECUTE private_conversation_index_stmt;
+DEALLOCATE PREPARE private_conversation_index_stmt;
diff --git a/db/im-platform.sql b/db/im-platform.sql
index 20d1c71..c4eb455 100644
--- a/db/im-platform.sql
+++ b/db/im-platform.sql
@@ -39,7 +39,8 @@ create table `im_private_message`(
`status` tinyint(1) NOT NULL comment '状态 0:未读 1:已读 2:撤回 3:已读',
`send_time` datetime DEFAULT CURRENT_TIMESTAMP comment '发送时间',
key `idx_send_id` (`send_id`),
- key `idx_recv_id` (`recv_id`)
+ key `idx_recv_id` (`recv_id`),
+ key `idx_private_pair_time` (`send_id`, `recv_id`, `send_time`, `id`)
)ENGINE=InnoDB CHARSET=utf8mb4 comment '私聊消息';
@@ -109,4 +110,4 @@ CREATE TABLE `im_file_info` (
`is_permanent` tinyint DEFAULT 0 comment '是否永久文件',
`md5` VARCHAR(64) NOT NULL comment '文件md5',
KEY `idx_md5` (md5)
-) ENGINE = InnoDB CHARSET = utf8mb4 comment '文件';
\ No newline at end of file
+) ENGINE = InnoDB CHARSET = utf8mb4 comment '文件';
diff --git a/im-platform/pom.xml b/im-platform/pom.xml
index 6a04dfb..050f480 100644
--- a/im-platform/pom.xml
+++ b/im-platform/pom.xml
@@ -96,6 +96,11 @@
knife4j-openapi3-jakarta-spring-boot-starter
${knife4j.version}
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
${project.artifactId}
@@ -113,4 +118,4 @@
-
\ No newline at end of file
+
diff --git a/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationController.java b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationController.java
new file mode 100644
index 0000000..b55a1a0
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationController.java
@@ -0,0 +1,27 @@
+package com.bx.implatform.controller.third;
+
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageModerateDTO;
+import com.bx.implatform.result.Result;
+import com.bx.implatform.result.ResultUtils;
+import com.bx.implatform.service.PrivateMessageModerationService;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageModerateVO;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/third/privateMsg")
+@RequiredArgsConstructor
+public class ThirdPrivateMessageModerationController {
+ private final PrivateMessageModerationService moderationService;
+
+ @PostMapping("/moderate")
+ @Operation(summary = "管理员屏蔽私聊消息,并通知双方隐藏原内容")
+ public Result moderate(@Valid @RequestBody ThirdPrivateMessageModerateDTO dto) {
+ return ResultUtils.success(moderationService.moderate(dto));
+ }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMsgController.java b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMsgController.java
index 78ec64a..383767f 100644
--- a/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMsgController.java
+++ b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdPrivateMsgController.java
@@ -1,6 +1,7 @@
package com.bx.implatform.controller.third;
import com.bx.implatform.dto.MessageTotalDto;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageCountDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageInfoDTO;
@@ -11,6 +12,7 @@ import com.bx.implatform.service.third.ThirdPrivateMsgService;
import com.bx.implatform.vo.MessageTotalVo;
import com.bx.implatform.vo.PrivateMessageVO;
import com.bx.implatform.vo.third.ThirdTableVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -34,6 +36,13 @@ public class ThirdPrivateMsgController {
private final ThirdPrivateMsgService thirdPrivateMsgService;
+ @PostMapping("/conversation/page")
+ @Operation(summary = "查询双人私聊会话列表")
+ public Result> conversationPage(
+ @Valid @RequestBody ThirdPrivateConversationDTO dto) {
+ return ResultUtils.success(thirdPrivateMsgService.conversationPage(dto));
+ }
+
@PostMapping("/send")
@Operation(summary = "发送私聊消息", description = "发送私聊消息")
public Result sendMessage(@Valid @RequestBody ThirdPrivateMessageSendDTO vo) {
diff --git a/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdUserController.java b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdUserController.java
index ecd357d..dbe19e0 100644
--- a/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdUserController.java
+++ b/im-platform/src/main/java/com/bx/implatform/controller/third/ThirdUserController.java
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -41,9 +42,9 @@ public class ThirdUserController {
return ResultUtils.success(thirdUserService.getUserByUserId(dto));
}
- @PostMapping("/createOrUpdate")
+ @PostMapping(value = "/createOrUpdate", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "创建或者更新用户", description = "创建或者更新用户")
- public Result createOrUpdate(@Valid @RequestBody ThirdCreateOrUpdateUserDTO dto) {
+ public Result createOrUpdate(@Valid @RequestBody ThirdCreateOrUpdateUserDTO dto) {
thirdUserService.createOrUpdate(dto);
return ResultUtils.success();
}
diff --git a/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateConversationDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateConversationDTO.java
new file mode 100644
index 0000000..72cb7e9
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateConversationDTO.java
@@ -0,0 +1,36 @@
+package com.bx.implatform.dto.third.privatemsg;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+@Schema(description = "第三方私聊会话分页查询")
+public class ThirdPrivateConversationDTO {
+
+ @NotNull
+ @Min(1)
+ private Integer pageNum = 1;
+
+ @NotNull
+ @Min(1)
+ @Max(100)
+ private Integer pageSize = 10;
+
+ @Size(max = 100)
+ @Schema(description = "任意一方的昵称、账号或完整用户ID")
+ private String userKeyword;
+
+ @Size(max = 100)
+ @Schema(description = "另一方的昵称、账号或完整用户ID")
+ private String peerKeyword;
+
+ @Schema(description = "最近聊天时间范围,包含两端,毫秒时间戳数组")
+ private List lastSendTimeList;
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateMessageModerateDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateMessageModerateDTO.java
new file mode 100644
index 0000000..ba13426
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/dto/third/privatemsg/ThirdPrivateMessageModerateDTO.java
@@ -0,0 +1,20 @@
+package com.bx.implatform.dto.third.privatemsg;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class ThirdPrivateMessageModerateDTO {
+ @NotNull
+ @Min(1)
+ private Long messageId;
+
+ @NotNull
+ @Min(1)
+ private Long operatorId;
+
+ @Size(max = 200)
+ private String reason;
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/dto/third/user/ThirdCreateOrUpdateUserDTO.java b/im-platform/src/main/java/com/bx/implatform/dto/third/user/ThirdCreateOrUpdateUserDTO.java
index 526143d..bcb70fd 100644
--- a/im-platform/src/main/java/com/bx/implatform/dto/third/user/ThirdCreateOrUpdateUserDTO.java
+++ b/im-platform/src/main/java/com/bx/implatform/dto/third/user/ThirdCreateOrUpdateUserDTO.java
@@ -1,7 +1,7 @@
package com.bx.implatform.dto.third.user;
import io.swagger.v3.oas.annotations.media.Schema;
-import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@@ -15,11 +15,11 @@ public class ThirdCreateOrUpdateUserDTO {
private Long userId;
@Length(max = 20, message = "用户名不能大于20字符")
- @NotEmpty(message = "用户名不可为空")
+ @NotBlank(message = "用户名不可为空")
@Schema(description = "用户名")
private String userName;
- @NotEmpty(message = "用户手机号不可为空")
+ @NotBlank(message = "用户手机号不可为空")
@Schema(description = "用户手机号")
private String phone;
diff --git a/im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java b/im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java
index ba61cd2..be09961 100644
--- a/im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java
+++ b/im-platform/src/main/java/com/bx/implatform/listener/PrivateMessageListener.java
@@ -9,6 +9,8 @@ import com.bx.imcommon.enums.IMSendCode;
import com.bx.imcommon.model.IMSendResult;
import com.bx.implatform.entity.PrivateMessage;
import com.bx.implatform.enums.MessageStatus;
+import com.bx.implatform.enums.MessageType;
+import com.bx.implatform.service.PrivateMessageModerationService;
import com.bx.implatform.service.PrivateMessageService;
import com.bx.implatform.vo.PrivateMessageVO;
import lombok.extern.slf4j.Slf4j;
@@ -27,17 +29,23 @@ public class PrivateMessageListener implements MessageListener
@Autowired
private PrivateMessageService privateMessageService;
+ @Lazy
+ @Autowired
+ private PrivateMessageModerationService moderationService;
+
@Override
public void process(List> results) {
Set messageIds = new HashSet<>();
for (IMSendResult result : results) {
+ if (result == null || result.getData() == null || result.getReceiver() == null) continue;
PrivateMessageVO messageInfo = result.getData();
// 更新消息状态,这里只处理成功消息,失败的消息继续保持未读状态
- if (result.getCode().equals(IMSendCode.SUCCESS.code()) && !Objects.isNull(messageInfo.getId())) {
- if (result.getReceiver().getId().equals(messageInfo.getRecvId())) {
+ if (IMSendCode.SUCCESS.code().equals(result.getCode()) && messageInfo.getId() != null
+ && messageInfo.getId() > 0 && !MessageType.RECALL.code().equals(messageInfo.getType())) {
+ if (Objects.equals(result.getReceiver().getId(), messageInfo.getRecvId())) {
messageIds.add(messageInfo.getId());
log.info("消息送达,消息id:{},发送者:{},接收者:{},终端:{}", messageInfo.getId(),
- result.getSender().getId(), result.getReceiver().getId(), result.getReceiver().getTerminal());
+ messageInfo.getSendId(), result.getReceiver().getId(), result.getReceiver().getTerminal());
}
}
}
@@ -48,6 +56,9 @@ public class PrivateMessageListener implements MessageListener
.eq(PrivateMessage::getStatus, MessageStatus.PENDING.code())
.set(PrivateMessage::getStatus, MessageStatus.DELIVERED.code());
privateMessageService.update(updateWrapper);
+ // The source message could have been queued before moderation and delivered
+ // afterwards. Re-send the persisted tombstone without requesting a callback.
+ moderationService.replayIfModerated(messageIds);
}
}
}
diff --git a/im-platform/src/main/java/com/bx/implatform/mapper/PrivateMessageMapper.java b/im-platform/src/main/java/com/bx/implatform/mapper/PrivateMessageMapper.java
index 304324d..bdcc30a 100644
--- a/im-platform/src/main/java/com/bx/implatform/mapper/PrivateMessageMapper.java
+++ b/im-platform/src/main/java/com/bx/implatform/mapper/PrivateMessageMapper.java
@@ -2,8 +2,10 @@ package com.bx.implatform.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bx.implatform.dto.MessageTotalDto;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
import com.bx.implatform.entity.PrivateMessage;
import com.bx.implatform.vo.MessageTotalVo;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -12,6 +14,19 @@ import java.util.Map;
public interface PrivateMessageMapper extends BaseMapper {
+ @Select("SELECT * FROM im_private_message WHERE id = #{id} FOR UPDATE")
+ PrivateMessage selectForUpdate(@Param("id") Long id);
+
+ @Select("SELECT * FROM im_private_message WHERE send_id = #{sendId} AND recv_id = #{recvId} "
+ + "AND type = 10 AND BINARY tmp_id = #{marker} ORDER BY id LIMIT 1")
+ PrivateMessage selectModerationEvent(@Param("marker") String marker, @Param("sendId") Long sendId,
+ @Param("recvId") Long recvId);
+
+ long countConversations(@Param("dto") ThirdPrivateConversationDTO dto);
+
+ List selectConversations(@Param("dto") ThirdPrivateConversationDTO dto,
+ @Param("offset") long offset);
+
@Select("SELECT DATE(send_time) as date, COUNT(*) as count " +
"FROM im_private_message " +
"WHERE send_time >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY) " +
diff --git a/im-platform/src/main/java/com/bx/implatform/service/PrivateMessageModerationService.java b/im-platform/src/main/java/com/bx/implatform/service/PrivateMessageModerationService.java
new file mode 100644
index 0000000..d0fc8c2
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/service/PrivateMessageModerationService.java
@@ -0,0 +1,198 @@
+package com.bx.implatform.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bx.imclient.IMClient;
+import com.bx.imcommon.enums.IMTerminalType;
+import com.bx.imcommon.model.IMPrivateMessage;
+import com.bx.imcommon.model.IMUserInfo;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageModerateDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.enums.MessageStatus;
+import com.bx.implatform.enums.MessageType;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.util.BeanUtils;
+import com.bx.implatform.vo.PrivateMessageVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageModerateVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/** Persists moderation before notifying clients using their existing recall protocol. */
+@Slf4j
+@Service
+public class PrivateMessageModerationService {
+ public static final String ADMIN_MARKER_PREFIX = "admin-m:";
+
+ private final PrivateMessageMapper mapper;
+ private final IMClient imClient;
+ private final TransactionTemplate transactionTemplate;
+
+ public PrivateMessageModerationService(PrivateMessageMapper mapper, IMClient imClient,
+ TransactionTemplate transactionTemplate) {
+ this.mapper = mapper;
+ this.imClient = imClient;
+ this.transactionTemplate = new TransactionTemplate(transactionTemplate.getTransactionManager());
+ // Ensure execute() has really committed before the notification is submitted,
+ // even if a future caller has an outer transaction.
+ this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+ }
+
+ public ThirdPrivateMessageModerateVO moderate(ThirdPrivateMessageModerateDTO dto) {
+ if (dto == null || dto.getMessageId() == null || dto.getMessageId() <= 0
+ || dto.getOperatorId() == null || dto.getOperatorId() <= 0
+ || (dto.getReason() != null && dto.getReason().length() > 200)) {
+ throw new GlobalException("消息编号、操作人或屏蔽原因不正确");
+ }
+ ModerationChange change = transactionTemplate.execute(transaction -> {
+ PrivateMessage original = mapper.selectForUpdate(dto.getMessageId());
+ if (original == null) throw new GlobalException("消息不存在");
+ if (!isContentMessage(original.getType())) throw new GlobalException("状态或控制消息不能屏蔽");
+
+ boolean alreadyHidden = MessageStatus.RECALL.code().equals(original.getStatus());
+ if (!alreadyHidden) {
+ PrivateMessage update = new PrivateMessage();
+ update.setId(original.getId());
+ update.setStatus(MessageStatus.RECALL.code());
+ if (mapper.updateById(update) != 1) throw new GlobalException("消息状态更新失败");
+ }
+ String marker = ADMIN_MARKER_PREFIX + original.getId();
+ PrivateMessage event = mapper.selectModerationEvent(marker, original.getSendId(), original.getRecvId());
+ if (event == null) {
+ event = new PrivateMessage();
+ event.setTmpId(marker);
+ event.setSendId(original.getSendId());
+ event.setRecvId(original.getRecvId());
+ event.setContent(original.getId().toString());
+ event.setType(MessageType.RECALL.code());
+ event.setStatus(MessageStatus.READED.code());
+ event.setSendTime(new Date());
+ if (mapper.insert(event) != 1) throw new GlobalException("撤回同步记录保存失败");
+ } else if (!isAdminModerationEvent(event) || !original.getId().toString().equals(event.getContent())) {
+ throw new GlobalException("撤回同步记录不正确");
+ }
+ return new ModerationChange(original.getId(), event, alreadyHidden);
+ });
+ if (change == null) throw new GlobalException("消息屏蔽失败");
+
+ boolean notificationSent = notifyParticipants(change.event());
+ String reason = dto.getReason() == null ? "" : dto.getReason().replaceAll("[\\r\\n\\t]", " ");
+ log.info("管理员屏蔽私聊消息,messageId={},operatorId={},reason={},alreadyHidden={},recallMessageId={},notificationSent={}",
+ change.messageId(), dto.getOperatorId(), reason, change.alreadyHidden(), change.event().getId(), notificationSent);
+ ThirdPrivateMessageModerateVO result = new ThirdPrivateMessageModerateVO();
+ result.setMessageId(change.messageId());
+ result.setStatus(MessageStatus.RECALL.code());
+ result.setRecallMessageId(change.event().getId());
+ result.setAlreadyHidden(change.alreadyHidden());
+ result.setNotificationSent(notificationSent);
+ return result;
+ }
+
+ /** A delayed delivery acknowledgement may arrive after moderation. Reassert the tombstone. */
+ public void replayIfModerated(Long sourceMessageId) {
+ replayIfModerated(Collections.singletonList(sourceMessageId));
+ }
+
+ /** The normal delivery path needs one narrow batch query, never one body lookup per message. */
+ public void replayIfModerated(Collection sourceMessageIds) {
+ if (sourceMessageIds == null || sourceMessageIds.isEmpty()) return;
+ List ids = sourceMessageIds.stream().filter(id -> id != null && id > 0).distinct().collect(Collectors.toList());
+ for (int start = 0; start < ids.size(); start += 500) {
+ List batch = ids.subList(start, Math.min(start + 500, ids.size()));
+ try {
+ List sources = mapper.selectList(Wrappers.lambdaQuery()
+ .select(PrivateMessage::getId, PrivateMessage::getSendId, PrivateMessage::getRecvId,
+ PrivateMessage::getType, PrivateMessage::getStatus)
+ .in(PrivateMessage::getId, batch).eq(PrivateMessage::getStatus, MessageStatus.RECALL.code()));
+ for (PrivateMessage source : sources) {
+ if (!isContentMessage(source.getType())) continue;
+ PrivateMessage event = mapper.selectModerationEvent(ADMIN_MARKER_PREFIX + source.getId(),
+ source.getSendId(), source.getRecvId());
+ if (isAdminModerationEvent(event) && source.getId().toString().equals(event.getContent())) {
+ notifyParticipants(event);
+ }
+ }
+ } catch (Exception e) {
+ log.warn("管理员撤回补偿失败,batchSize={},exceptionType={}", batch.size(), e.getClass().getSimpleName());
+ }
+ }
+ }
+
+ private boolean notifyParticipants(PrivateMessage event) {
+ boolean submitted = true;
+ for (Long participant : new LinkedHashSet<>(Arrays.asList(event.getSendId(), event.getRecvId()))) {
+ IMPrivateMessage push = new IMPrivateMessage<>();
+ push.setSender(new IMUserInfo(event.getSendId(), IMTerminalType.UNKNOW.code()));
+ push.setRecvId(participant);
+ push.setRecvTerminals(IMTerminalType.codes());
+ push.setSendToSelf(false);
+ push.setSendResult(false);
+ push.setData(toMobileVO(event));
+ try {
+ imClient.sendPrivateMessage(push);
+ } catch (Exception e) {
+ submitted = false;
+ log.warn("管理员撤回通知提交失败,eventId={},participantId={},exceptionType={}",
+ event.getId(), participant, e.getClass().getSimpleName());
+ }
+ }
+ return submitted;
+ }
+
+ public PrivateMessageVO toMobileVO(PrivateMessage message) {
+ PrivateMessageVO vo = BeanUtils.copyProperties(message, PrivateMessageVO.class);
+ if (MessageType.RECALL.code().equals(message.getType())) {
+ Long originalId = recallTargetId(message.getContent());
+ // Old uni-app uses content to find the message and id to find its quotes.
+ // The database event id is retained in storage/query ordering only.
+ if (originalId != null) vo.setId(originalId);
+ if (isAdminModerationEvent(message)) vo.setStatus(MessageStatus.READED.code());
+ }
+ return vo;
+ }
+
+ /** Re-read after pending/delivered updates so a stale snapshot cannot re-expose hidden content. */
+ public List refreshVisibleMessages(List messages) {
+ if (messages == null || messages.isEmpty()) return Collections.emptyList();
+ List ids = messages.stream().map(PrivateMessage::getId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+ Map current = new HashMap<>();
+ for (int start = 0; start < ids.size(); start += 500) {
+ List rows = mapper.selectBatchIds(ids.subList(start, Math.min(start + 500, ids.size())));
+ for (PrivateMessage row : rows) current.put(row.getId(), row);
+ }
+ return messages.stream().map(message -> current.get(message.getId())).filter(Objects::nonNull)
+ .filter(message -> !MessageStatus.RECALL.code().equals(message.getStatus())).collect(Collectors.toList());
+ }
+
+ public static boolean isAdminModerationEvent(PrivateMessage message) {
+ return message != null && MessageType.RECALL.code().equals(message.getType())
+ && recallTargetId(message.getContent()) != null
+ && (ADMIN_MARKER_PREFIX + message.getContent()).equals(message.getTmpId());
+ }
+
+ public static void rejectReservedTmpId(String tmpId) {
+ if (tmpId != null && tmpId.toLowerCase(Locale.ROOT).startsWith(ADMIN_MARKER_PREFIX)) {
+ throw new GlobalException("临时消息编号使用了保留前缀");
+ }
+ }
+
+ private static Long recallTargetId(String content) {
+ try {
+ long id = Long.parseLong(content);
+ return id > 0 ? id : null;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ private static boolean isContentMessage(Integer type) {
+ return type != null && ((type >= 0 && type <= 9)
+ || MessageType.ACT_RT_VOICE.code().equals(type) || MessageType.ACT_RT_VIDEO.code().equals(type));
+ }
+
+ private record ModerationChange(Long messageId, PrivateMessage event, boolean alreadyHidden) { }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
index 3401f8a..e964525 100644
--- a/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
+++ b/im-platform/src/main/java/com/bx/implatform/service/impl/PrivateMessageServiceImpl.java
@@ -19,9 +19,11 @@ import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.mapper.PrivateMessageMapper;
import com.bx.implatform.service.FriendService;
import com.bx.implatform.service.PrivateMessageService;
+import com.bx.implatform.service.PrivateMessageModerationService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession;
import com.bx.implatform.util.BeanUtils;
+import com.bx.implatform.util.PrivateMessageSessionControl;
import com.bx.implatform.util.SensitiveFilterUtil;
import com.bx.implatform.vo.PrivateMessageVO;
import lombok.RequiredArgsConstructor;
@@ -46,10 +48,13 @@ public class PrivateMessageServiceImpl extends ServiceImpl sendMessage = new IMPrivateMessage<>();
sendMessage.setSender(new IMUserInfo(session.getUserId(), session.getTerminal()));
sendMessage.setRecvId(msgInfo.getRecvId());
sendMessage.setData(msgInfo);
+ // Recall VO ids point to the original message for old client compatibility.
+ sendMessage.setSendResult(false);
imClient.sendPrivateMessage(sendMessage);
log.info("撤回私聊消息,发送id:{},接收id:{},内容:{}", msg.getSendId(), msg.getRecvId(), msg.getContent());
return msgInfo;
@@ -122,6 +132,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl wrapper = new QueryWrapper<>();
+ PrivateMessageSessionControl.applyHistoryFilter(wrapper);
wrapper.lambda().and(
wrap -> wrap.and(wp -> wp.eq(PrivateMessage::getSendId, userId).eq(PrivateMessage::getRecvId, friendId))
.or(wp -> wp.eq(PrivateMessage::getRecvId, userId).eq(PrivateMessage::getSendId, friendId)))
@@ -130,7 +141,9 @@ public class PrivateMessageServiceImpl extends ServiceImpl messages = this.list(wrapper);
List messageInfos =
- messages.stream().map(m -> BeanUtils.copyProperties(m, PrivateMessageVO.class))
+ moderationService.refreshVisibleMessages(messages).stream()
+ .filter(m -> !PrivateMessageSessionControl.isKickMessage(m))
+ .map(moderationService::toMobileVO)
.collect(Collectors.toList());
log.info("拉取聊天记录,用户id:{},好友id:{},数量:{}", userId, friendId, messageInfos.size());
return messageInfos;
@@ -139,34 +152,29 @@ public class PrivateMessageServiceImpl extends ServiceImpl wrapper = Wrappers.lambdaQuery();
- // 只能拉取最近1个月的消息
- Date minDate = DateUtils.addMonths(new Date(), -1);
- wrapper.gt(PrivateMessage::getId, minId);
- wrapper.ge(PrivateMessage::getSendTime, minDate);
- wrapper.and(wp -> wp.eq(PrivateMessage::getSendId, session.getUserId()).or()
- .eq(PrivateMessage::getRecvId, session.getUserId()));
- wrapper.orderByAsc(PrivateMessage::getId);
- List messages = this.list(wrapper);
+ List messages = this.list(buildOfflineQuery(session.getUserId(), minId));
// 异步推送消息
EXECUTOR.execute(() -> {
// 开启加载中标志
this.sendLoadingMessage(true, session);
- for (PrivateMessage m : messages) {
+ for (PrivateMessage m : moderationService.refreshVisibleMessages(messages)) {
+ // Historical disable notices must not terminate a new login session.
+ if (PrivateMessageSessionControl.isKickMessage(m)) {
+ continue;
+ }
// 推送过程如果用户下线了,则不再推送
if (!imClient.isOnline(session.getUserId(), IMTerminalType.fromCode(session.getTerminal()))) {
log.info("用户已下线,停止推送离线私聊消息,用户id:{}", session.getUserId());
return;
}
- PrivateMessageVO vo = BeanUtils.copyProperties(m, PrivateMessageVO.class);
+ PrivateMessageVO vo = moderationService.toMobileVO(m);
IMPrivateMessage sendMessage = new IMPrivateMessage<>();
sendMessage.setSender(new IMUserInfo(m.getSendId(), IMTerminalType.WEB.code()));
sendMessage.setRecvId(session.getUserId());
sendMessage.setRecvTerminals(List.of(session.getTerminal()));
sendMessage.setSendToSelf(false);
sendMessage.setData(vo);
- sendMessage.setSendResult(true);
+ sendMessage.setSendResult(!MessageType.RECALL.code().equals(m.getType()));
imClient.sendPrivateMessage(sendMessage);
}
// 关闭加载中标志
@@ -178,16 +186,7 @@ public class PrivateMessageServiceImpl extends ServiceImpl loadOfflineMessage(Long minId) {
UserSession session = SessionContext.getSession();
- // 获取当前用户的消息
- LambdaQueryWrapper wrapper = Wrappers.lambdaQuery();
- // 只能拉取最近1个月的消息
- Date minDate = DateUtils.addMonths(new Date(), -1);
- wrapper.gt(PrivateMessage::getId, minId);
- wrapper.ge(PrivateMessage::getSendTime, minDate);
- wrapper.and(wp -> wp.eq(PrivateMessage::getSendId, session.getUserId()).or()
- .eq(PrivateMessage::getRecvId, session.getUserId()));
- wrapper.orderByAsc(PrivateMessage::getId);
- List messages = this.list(wrapper);
+ List messages = this.list(buildOfflineQuery(session.getUserId(), minId));
// 更新消息为送达状态
List messageIds = messages.stream().filter(m -> m.getRecvId().equals(session.getUserId()))
.filter(m -> m.getStatus().equals(MessageStatus.PENDING.code())).map(PrivateMessage::getId)
@@ -195,16 +194,37 @@ public class PrivateMessageServiceImpl extends ServiceImpl updateWrapper = Wrappers.lambdaUpdate();
updateWrapper.in(PrivateMessage::getId, messageIds);
+ updateWrapper.eq(PrivateMessage::getStatus, MessageStatus.PENDING.code());
updateWrapper.set(PrivateMessage::getStatus, MessageStatus.DELIVERED.code());
update(updateWrapper);
}
// 转换vo
- List vos = messages.stream().map(m -> BeanUtils.copyProperties(m, PrivateMessageVO.class))
+ List vos = moderationService.refreshVisibleMessages(messages).stream()
+ .filter(m -> !PrivateMessageSessionControl.isKickMessage(m))
+ .map(moderationService::toMobileVO)
.collect(Collectors.toList());
log.info("拉取私聊消息,用户id:{},数量:{},minId:{}", session.getUserId(), messages.size(), minId);
return vos;
}
+ private LambdaQueryWrapper buildOfflineQuery(Long userId, Long minId) {
+ Date minDate = DateUtils.addMonths(new Date(), -1);
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery();
+ wrapper.and(pair -> pair.eq(PrivateMessage::getSendId, userId).or().eq(PrivateMessage::getRecvId, userId));
+ wrapper.ne(PrivateMessage::getStatus, MessageStatus.RECALL.code());
+ PrivateMessageSessionControl.applyHistoryFilter(wrapper);
+ wrapper.and(eligible -> eligible
+ .and(recent -> recent.gt(PrivateMessage::getId, minId == null ? 0L : minId)
+ .ge(PrivateMessage::getSendTime, minDate))
+ // Older clients do not acknowledge recall events. Replay administrator
+ // tombstones independently of the cursor and one-month message window.
+ .or(admin -> admin.eq(PrivateMessage::getType, MessageType.RECALL.code())
+ .eq(PrivateMessage::getStatus, MessageStatus.READED.code())
+ .apply("BINARY tmp_id = CONCAT({0}, content)", PrivateMessageModerationService.ADMIN_MARKER_PREFIX)));
+ wrapper.orderByAsc(PrivateMessage::getId);
+ return wrapper;
+ }
+
@Transactional(rollbackFor = Exception.class)
@Override
public void readedMessage(Long friendId) {
diff --git a/im-platform/src/main/java/com/bx/implatform/service/third/ThirdPrivateMsgService.java b/im-platform/src/main/java/com/bx/implatform/service/third/ThirdPrivateMsgService.java
index 078dd77..5eb91eb 100644
--- a/im-platform/src/main/java/com/bx/implatform/service/third/ThirdPrivateMsgService.java
+++ b/im-platform/src/main/java/com/bx/implatform/service/third/ThirdPrivateMsgService.java
@@ -1,6 +1,7 @@
package com.bx.implatform.service.third;
import com.bx.implatform.dto.MessageTotalDto;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageCountDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageInfoDTO;
@@ -8,6 +9,7 @@ import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageSendDTO;
import com.bx.implatform.vo.MessageTotalVo;
import com.bx.implatform.vo.PrivateMessageVO;
import com.bx.implatform.vo.third.ThirdTableVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageVO;
import jakarta.validation.Valid;
@@ -16,6 +18,8 @@ import java.util.Map;
public interface ThirdPrivateMsgService {
+ ThirdTableVO conversationPage(@Valid ThirdPrivateConversationDTO dto);
+
/**
* 发送私聊消息
*/
diff --git a/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdPrivateMsgServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdPrivateMsgServiceImpl.java
index efd2090..c347a2d 100644
--- a/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdPrivateMsgServiceImpl.java
+++ b/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdPrivateMsgServiceImpl.java
@@ -5,6 +5,7 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.IterUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -14,6 +15,7 @@ import com.bx.imcommon.model.IMPrivateMessage;
import com.bx.imcommon.model.IMUserInfo;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.dto.MessageTotalDto;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageCountDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageDTO;
import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageInfoDTO;
@@ -26,18 +28,22 @@ import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.mapper.PrivateMessageMapper;
import com.bx.implatform.mapper.UserMapper;
import com.bx.implatform.service.FriendService;
+import com.bx.implatform.service.PrivateMessageModerationService;
import com.bx.implatform.service.third.ThirdPrivateMsgService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession;
import com.bx.implatform.util.BeanUtils;
+import com.bx.implatform.util.PrivateMessageSessionControl;
import com.bx.implatform.util.SensitiveFilterUtil;
import com.bx.implatform.vo.MessageTotalVo;
import com.bx.implatform.vo.PrivateMessageVO;
import com.bx.implatform.vo.third.ThirdTableVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@@ -56,11 +62,32 @@ public class ThirdPrivateMsgServiceImpl extends ServiceImpl conversationPage(ThirdPrivateConversationDTO dto) {
+ validatePagination(dto.getPageNum(), dto.getPageSize());
+ validateTimeRange(dto.getLastSendTimeList());
+ dto.setUserKeyword(StrUtil.trimToNull(dto.getUserKeyword()));
+ dto.setPeerKeyword(StrUtil.trimToNull(dto.getPeerKeyword()));
+ long total = privateMessageMapper.countConversations(dto);
+ long offset = (long) (dto.getPageNum() - 1) * dto.getPageSize();
+ if (offset >= total) {
+ return new ThirdTableVO<>(Collections.emptyList(), total);
+ }
+ List rows = privateMessageMapper.selectConversations(dto, offset);
+ return new ThirdTableVO<>(rows == null ? Collections.emptyList() : rows, total);
+ }
+
/**
* 发送私聊消息
*/
@Override
public PrivateMessageVO sendMessage(ThirdPrivateMessageSendDTO dto) {
+ PrivateMessageModerationService.rejectReservedTmpId(dto.getTmpId());
+ PrivateMessageSessionControl.validateKickMessage(dto.getSendId(), dto.getType(), dto.getContent());
+ if (PrivateMessageSessionControl.hasKickPrefix(dto.getContent())) {
+ return sendSessionControl(dto);
+ }
Boolean isFriends = friendService.isFriend(dto.getSendId(), dto.getRecvId());
if (Boolean.FALSE.equals(isFriends)) {
throw new GlobalException("您已不是对方好友,无法发送消息");
@@ -90,13 +117,34 @@ public class ThirdPrivateMsgServiceImpl extends ServiceImpl push = new IMPrivateMessage<>();
+ push.setSender(new IMUserInfo(dto.getSendId(), dto.getTerminal()));
+ push.setRecvId(dto.getRecvId());
+ push.setSendToSelf(false);
+ push.setSendResult(false);
+ push.setData(command);
+ imClient.sendPrivateMessage(push);
+ log.info("发送实时会话控制指令,接收id:{}", dto.getRecvId());
+ return command;
+ }
+
/**
* 查询私聊消息列表
*/
@Override
public ThirdTableVO page(ThirdPrivateMessageDTO dto) {
- ThirdTableVO result = new ThirdTableVO();
- Page page = baseMapper.selectPage(new Page(dto.getPageNum(), dto.getPageSize()), buildQueryWrapper(dto));
+ if (dto.getPageNum() == null) dto.setPageNum(1);
+ if (dto.getPageSize() == null) dto.setPageSize(10);
+ validatePagination(dto.getPageNum(), dto.getPageSize());
+ validateTimeRange(dto.getSendTimeList());
+ ThirdTableVO result = new ThirdTableVO<>(Collections.emptyList(), 0);
+ Page page = baseMapper.selectPage(new Page<>(dto.getPageNum(), dto.getPageSize()), buildQueryWrapper(dto));
result.setTotal(page.getTotal());
if (ObjectUtil.isNotEmpty(page.getRecords())) {
@@ -210,7 +258,7 @@ public class ThirdPrivateMsgServiceImpl extends ServiceImpl params = bo.getParams();
+ Map params = bo.getParams() == null ? Collections.emptyMap() : bo.getParams();
LambdaQueryWrapper wrapper = Wrappers.lambdaQuery();
if (!Objects.isNull(bo.getSendId()) && !Objects.isNull(bo.getRecvId())) {
// 指定了两个用户,返回这两个用户之间的聊天记录
@@ -241,8 +289,26 @@ public class ThirdPrivateMsgServiceImpl extends ServiceImpl 100) {
+ throw new GlobalException("页码必须大于0,每页数量须在1到100之间");
+ }
+ }
+
+ private void validateTimeRange(List times) {
+ if (times == null || times.isEmpty()) return;
+ if (times.size() != 2 || times.get(0) == null || times.get(1) == null
+ || times.get(0).after(times.get(1))) {
+ throw new GlobalException("时间范围必须包含有效的开始时间和结束时间,且开始时间不能晚于结束时间");
+ }
+ }
+
}
diff --git a/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdUserServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdUserServiceImpl.java
index c029ab9..b6f0237 100644
--- a/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdUserServiceImpl.java
+++ b/im-platform/src/main/java/com/bx/implatform/service/third/impl/ThirdUserServiceImpl.java
@@ -12,6 +12,7 @@ import com.bx.implatform.dto.third.user.ThirdGetUserByUserIdDTO;
import com.bx.implatform.dto.third.user.ThirdLoginDTO;
import com.bx.implatform.dto.third.user.ThirdUserOnLineStatusDto;
import com.bx.implatform.entity.User;
+import com.bx.implatform.enums.ResultCode;
import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.mapper.UserMapper;
import com.bx.implatform.service.third.ThirdUserService;
@@ -22,6 +23,7 @@ import com.bx.implatform.vo.third.user.ThirdLoginVO;
import com.bx.implatform.vo.third.user.ThirdUserOnLineStatusVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -30,6 +32,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/**
@@ -76,22 +79,54 @@ public class ThirdUserServiceImpl extends ServiceImpl implemen
*/
@Override
public void createOrUpdate(ThirdCreateOrUpdateUserDTO dto) {
- // 查询用户
User user = baseMapper.selectById(dto.getUserId());
- User saveObjUser = new User();
- saveObjUser.setId(dto.getUserId());
- saveObjUser.setUserName(dto.getUserName());
- saveObjUser.setNickName(dto.getNickName());
- saveObjUser.setHeadImage(dto.getAvatar());
- saveObjUser.setHeadImageThumb(dto.getAvatar());
- if (ObjectUtil.isEmpty(user)) {
- // 创建用户
- saveObjUser.setPassword(passwordEncoder.encode(dto.generatePassword()));
- baseMapper.insert(saveObjUser);
- } else {
- // 更新用户
- baseMapper.updateById(saveObjUser);
+ if (user != null) {
+ updateProfileIfChanged(user, dto);
+ return;
}
+
+ User newUser = buildUserProfile(dto);
+ // 昵称是可选参数,但数据库不允许为空。
+ if (newUser.getNickName() == null) {
+ newUser.setNickName(dto.getUserName());
+ }
+ newUser.setPassword(passwordEncoder.encode(dto.generatePassword()));
+ try {
+ baseMapper.insert(newUser);
+ } catch (DuplicateKeyException e) {
+ // 多个后端实例可能同时初始化同一个公共账号。
+ User concurrentUser = baseMapper.selectById(dto.getUserId());
+ if (concurrentUser == null) {
+ throw new GlobalException(ResultCode.USERNAME_ALREADY_REGISTER);
+ }
+ updateProfileIfChanged(concurrentUser, dto);
+ }
+ }
+
+ private void updateProfileIfChanged(User user, ThirdCreateOrUpdateUserDTO dto) {
+ boolean unchanged = Objects.equals(user.getUserName(), dto.getUserName())
+ && (dto.getNickName() == null || Objects.equals(user.getNickName(), dto.getNickName()))
+ && (dto.getAvatar() == null || (Objects.equals(user.getHeadImage(), dto.getAvatar())
+ && Objects.equals(user.getHeadImageThumb(), dto.getAvatar())));
+ if (unchanged) {
+ return;
+ }
+ try {
+ // 只同步资料,保留已有密码、封禁状态等账号信息。
+ baseMapper.updateById(buildUserProfile(dto));
+ } catch (DuplicateKeyException e) {
+ throw new GlobalException(ResultCode.USERNAME_ALREADY_REGISTER);
+ }
+ }
+
+ private User buildUserProfile(ThirdCreateOrUpdateUserDTO dto) {
+ User user = new User();
+ user.setId(dto.getUserId());
+ user.setUserName(dto.getUserName());
+ user.setNickName(dto.getNickName());
+ user.setHeadImage(dto.getAvatar());
+ user.setHeadImageThumb(dto.getAvatar());
+ return user;
}
/**
diff --git a/im-platform/src/main/java/com/bx/implatform/util/PrivateMessageSessionControl.java b/im-platform/src/main/java/com/bx/implatform/util/PrivateMessageSessionControl.java
new file mode 100644
index 0000000..60c826e
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/util/PrivateMessageSessionControl.java
@@ -0,0 +1,54 @@
+package com.bx.implatform.util;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.bx.implatform.contant.Constant;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.enums.MessageType;
+import com.bx.implatform.exception.GlobalException;
+
+/** The existing mobile kick protocol is a live session command, never chat history. */
+public final class PrivateMessageSessionControl {
+ public static final String KICK_PREFIX = "__APP_KICK_SESSION__:";
+
+ // Literal, case-sensitive prefix comparison avoids treating underscores as LIKE wildcards.
+ // COALESCE keeps unrelated legacy records with NULL fields visible.
+ private static final String HISTORY_FILTER = "NOT (COALESCE(send_id, 0) = {0}"
+ + " AND COALESCE(type, -1) = {1}"
+ + " AND BINARY LEFT(COALESCE(content, ''), {2}) = BINARY {3})";
+
+ private PrivateMessageSessionControl() {
+ }
+
+ public static boolean hasKickPrefix(String content) {
+ return content != null && content.startsWith(KICK_PREFIX);
+ }
+
+ public static boolean isKickMessage(PrivateMessage message) {
+ return message != null && Constant.COM_MESSAGE_USER_ID.equals(message.getSendId())
+ && MessageType.TEXT.code().equals(message.getType()) && hasKickPrefix(message.getContent());
+ }
+
+ public static void rejectReservedKickContent(String content) {
+ if (hasKickPrefix(content)) {
+ throw new GlobalException("消息内容使用了保留的会话控制前缀");
+ }
+ }
+
+ public static void validateKickMessage(Long sendId, Integer type, String content) {
+ if (hasKickPrefix(content) && (!Constant.COM_MESSAGE_USER_ID.equals(sendId)
+ || !MessageType.TEXT.code().equals(type))) {
+ throw new GlobalException("会话控制消息只能由消息通知账号发送文本指令");
+ }
+ }
+
+ public static void applyHistoryFilter(LambdaQueryWrapper wrapper) {
+ wrapper.apply(HISTORY_FILTER, Constant.COM_MESSAGE_USER_ID, MessageType.TEXT.code(),
+ KICK_PREFIX.length(), KICK_PREFIX);
+ }
+
+ public static void applyHistoryFilter(QueryWrapper wrapper) {
+ wrapper.apply(HISTORY_FILTER, Constant.COM_MESSAGE_USER_ID, MessageType.TEXT.code(),
+ KICK_PREFIX.length(), KICK_PREFIX);
+ }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateConversationVO.java b/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateConversationVO.java
new file mode 100644
index 0000000..0cd7c27
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateConversationVO.java
@@ -0,0 +1,28 @@
+package com.bx.implatform.vo.third.privatemsg;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "第三方私聊会话,双方按照用户ID从小到大排列")
+public class ThirdPrivateConversationVO {
+
+ // Unique parent row identity for MyBatis nested result mappings.
+ @JsonIgnore
+ @Schema(hidden = true)
+ private Long latestMessageId;
+
+ private Participant userA;
+ private Participant userB;
+ private Long messageCount;
+ private ThirdPrivateMessageVO lastMessage;
+
+ @Data
+ public static class Participant {
+ private Long id;
+ private String nickname;
+ private String username;
+ private String avatar;
+ }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateMessageModerateVO.java b/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateMessageModerateVO.java
new file mode 100644
index 0000000..d712a3a
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/vo/third/privatemsg/ThirdPrivateMessageModerateVO.java
@@ -0,0 +1,13 @@
+package com.bx.implatform.vo.third.privatemsg;
+
+import lombok.Data;
+
+@Data
+public class ThirdPrivateMessageModerateVO {
+ private Long messageId;
+ private Integer status;
+ private Long recallMessageId;
+ private Boolean alreadyHidden;
+ /** Whether both online notification attempts were submitted, not a device acknowledgement. */
+ private Boolean notificationSent;
+}
diff --git a/im-platform/src/main/resources/mapper/PrivateMessageMapper.xml b/im-platform/src/main/resources/mapper/PrivateMessageMapper.xml
index 5907525..e5a95bc 100644
--- a/im-platform/src/main/resources/mapper/PrivateMessageMapper.xml
+++ b/im-platform/src/main/resources/mapper/PrivateMessageMapper.xml
@@ -3,6 +3,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (CAST(m.send_id AS CHAR) = #{dto.userKeyword}
+ OR su.nick_name LIKE CONCAT('%', #{dto.userKeyword}, '%')
+ OR su.user_name LIKE CONCAT('%', #{dto.userKeyword}, '%'))
+
+
+ (CAST(m.recv_id AS CHAR) = #{dto.userKeyword}
+ OR ru.nick_name LIKE CONCAT('%', #{dto.userKeyword}, '%')
+ OR ru.user_name LIKE CONCAT('%', #{dto.userKeyword}, '%'))
+
+
+ (CAST(m.send_id AS CHAR) = #{dto.peerKeyword}
+ OR su.nick_name LIKE CONCAT('%', #{dto.peerKeyword}, '%')
+ OR su.user_name LIKE CONCAT('%', #{dto.peerKeyword}, '%'))
+
+
+ (CAST(m.recv_id AS CHAR) = #{dto.peerKeyword}
+ OR ru.nick_name LIKE CONCAT('%', #{dto.peerKeyword}, '%')
+ OR ru.user_name LIKE CONCAT('%', #{dto.peerKeyword}, '%'))
+
+
+
+
+ SELECT LEAST(m.send_id, m.recv_id) AS user_a_id,
+ GREATEST(m.send_id, m.recv_id) AS user_b_id,
+ COUNT(*) AS message_count,
+ MAX(m.send_time) AS send_time
+ FROM im_private_message m
+
+ LEFT JOIN im_user su ON su.id = m.send_id
+ LEFT JOIN im_user ru ON ru.id = m.recv_id
+
+
+
+
+ (( AND )
+ OR ( AND ))
+
+
+ ( OR )
+
+
+ ( OR )
+
+
+
+ GROUP BY LEAST(m.send_id, m.recv_id), GREATEST(m.send_id, m.recv_id)
+
+
+
+
+
+ r.send_time BETWEEN #{dto.lastSendTimeList[0]} AND #{dto.lastSendTimeList[1]}
+
+
+
+
+
+
+
+
and a.send_id = #{dto.sendId}
diff --git a/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateConversationControllerTest.java b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateConversationControllerTest.java
new file mode 100644
index 0000000..4098952
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateConversationControllerTest.java
@@ -0,0 +1,134 @@
+package com.bx.implatform.controller.third;
+
+import com.bx.implatform.config.props.JwtProperties;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
+import com.bx.implatform.exception.GlobalExceptionHandler;
+import com.bx.implatform.filter.CacheFilter;
+import com.bx.implatform.interceptor.AuthInterceptor;
+import com.bx.implatform.service.third.ThirdAuthService;
+import com.bx.implatform.service.third.ThirdPrivateMsgService;
+import com.bx.implatform.vo.third.ThirdTableVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageVO;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class ThirdPrivateConversationControllerTest {
+ private static final String URL = "/third/privateMsg/conversation/page";
+
+ @Mock
+ private ThirdPrivateMsgService service;
+ @Mock
+ private ThirdAuthService authService;
+ private MockMvc mvc;
+ private boolean validSignature = true;
+
+ @BeforeEach
+ void setUp() {
+ when(authService.checkSign(any(HttpServletRequest.class))).thenAnswer(ignored -> validSignature);
+ mvc = MockMvcBuilders.standaloneSetup(new ThirdPrivateMsgController(service))
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .addInterceptors(new AuthInterceptor(new JwtProperties(), authService))
+ .addFilters(new CacheFilter()).build();
+ }
+
+ @Test
+ void responseKeepsBothParticipantsAndLastMessageInPagedContract() throws Exception {
+ ThirdPrivateConversationVO row = new ThirdPrivateConversationVO();
+ ThirdPrivateConversationVO.Participant a = new ThirdPrivateConversationVO.Participant();
+ a.setId(1L);
+ a.setNickname("Alice");
+ a.setUsername("alice-account");
+ a.setAvatar("https://example.test/alice.png");
+ ThirdPrivateConversationVO.Participant b = new ThirdPrivateConversationVO.Participant();
+ b.setId(2L);
+ b.setNickname("Bob");
+ row.setUserA(a);
+ row.setUserB(b);
+ row.setMessageCount(4L);
+ ThirdPrivateMessageVO last = new ThirdPrivateMessageVO();
+ last.setId(102L);
+ last.setSendId(2L);
+ last.setRecvId(1L);
+ last.setContent("last message");
+ last.setType(0L);
+ last.setStatus(3L);
+ row.setLastMessage(last);
+ when(service.conversationPage(any())).thenReturn(new ThirdTableVO<>(List.of(row), 1));
+
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.rows[0].userA.id").value(1))
+ .andExpect(jsonPath("$.data.rows[0].userA.nickname").value("Alice"))
+ .andExpect(jsonPath("$.data.rows[0].userB.id").value(2))
+ .andExpect(jsonPath("$.data.rows[0].messageCount").value(4))
+ .andExpect(jsonPath("$.data.rows[0].lastMessage.sendId").value(2));
+ }
+
+ @Test
+ void acceptsEpochMillisAndKeepsEmptyPageAsArray() throws Exception {
+ when(service.conversationPage(any())).thenReturn(new ThirdTableVO<>(Collections.emptyList(), 0));
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON)
+ .content("{\"lastSendTimeList\":[1793462400000,1793548799999]}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.rows").isArray())
+ .andExpect(jsonPath("$.data.rows").isEmpty())
+ .andExpect(jsonPath("$.data.total").value(0));
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPrivateConversationDTO.class);
+ verify(service).conversationPage(captor.capture());
+ assertEquals(1793462400000L, captor.getValue().getLastSendTimeList().get(0).getTime());
+ assertEquals(1793548799999L, captor.getValue().getLastSendTimeList().get(1).getTime());
+ assertEquals(1, captor.getValue().getPageNum());
+ assertEquals(10, captor.getValue().getPageSize());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"pageNum\":0}", "{\"pageNum\":-1}", "{\"pageSize\":0}", "{\"pageSize\":101}"})
+ void rejectsInvalidPageBoundsBeforeCallingService(String body) throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void invalidSignatureCannotReadConversations() throws Exception {
+ validSignature = false;
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void databaseFailureIsAnErrorRatherThanSuccessfulEmptyPage() throws Exception {
+ when(service.conversationPage(any())).thenThrow(new IllegalStateException("internal database failure"));
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("系统繁忙,请稍后再试"));
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationControllerTest.java b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationControllerTest.java
new file mode 100644
index 0000000..52c8218
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdPrivateMessageModerationControllerTest.java
@@ -0,0 +1,109 @@
+package com.bx.implatform.controller.third;
+
+import com.bx.implatform.config.props.JwtProperties;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageModerateDTO;
+import com.bx.implatform.exception.GlobalExceptionHandler;
+import com.bx.implatform.filter.CacheFilter;
+import com.bx.implatform.interceptor.AuthInterceptor;
+import com.bx.implatform.service.PrivateMessageModerationService;
+import com.bx.implatform.service.third.ThirdAuthService;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageModerateVO;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class ThirdPrivateMessageModerationControllerTest {
+ private static final String URL = "/third/privateMsg/moderate";
+ private static final String VALID_BODY = "{\"messageId\":101,\"operatorId\":42,\"reason\":\"违规内容\"}";
+ @Mock
+ private PrivateMessageModerationService service;
+ @Mock
+ private ThirdAuthService authService;
+ private MockMvc mvc;
+ private boolean validSignature = true;
+
+ @BeforeEach
+ void setUp() {
+ when(authService.checkSign(any(HttpServletRequest.class))).thenAnswer(ignored -> validSignature);
+ mvc = MockMvcBuilders.standaloneSetup(new ThirdPrivateMessageModerationController(service))
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .addInterceptors(new AuthInterceptor(new JwtProperties(), authService))
+ .addFilters(new CacheFilter()).build();
+ }
+
+ @Test
+ void returnsModerationReceiptAndKeepsOriginalMessageId() throws Exception {
+ ThirdPrivateMessageModerateVO response = new ThirdPrivateMessageModerateVO();
+ response.setMessageId(101L);
+ response.setStatus(2);
+ response.setRecallMessageId(202L);
+ response.setAlreadyHidden(false);
+ response.setNotificationSent(true);
+ when(service.moderate(any())).thenReturn(response);
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.messageId").value(101))
+ .andExpect(jsonPath("$.data.status").value(2))
+ .andExpect(jsonPath("$.data.recallMessageId").value(202))
+ .andExpect(jsonPath("$.data.alreadyHidden").value(false))
+ .andExpect(jsonPath("$.data.notificationSent").value(true));
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPrivateMessageModerateDTO.class);
+ verify(service).moderate(captor.capture());
+ assertEquals(42L, captor.getValue().getOperatorId());
+ assertEquals("违规内容", captor.getValue().getReason());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{}", "{\"messageId\":101}", "{\"operatorId\":42}",
+ "{\"messageId\":0,\"operatorId\":42}", "{\"messageId\":101,\"operatorId\":0}"})
+ void rejectsMissingOrInvalidIdsBeforeMutating(String body) throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void rejectsReasonLongerThanTwoHundredCharacters() throws Exception {
+ String body = "{\"messageId\":101,\"operatorId\":42,\"reason\":\"" + "x".repeat(201) + "\"}";
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void invalidSignatureCannotModerateAnyMessage() throws Exception {
+ validSignature = false;
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(401));
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void databaseFailureDoesNotReturnSuccessfulModerationReceipt() throws Exception {
+ when(service.moderate(any())).thenThrow(new IllegalStateException("database error"));
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("系统繁忙,请稍后再试"));
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdUserControllerTest.java b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdUserControllerTest.java
new file mode 100644
index 0000000..34091bb
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/controller/third/ThirdUserControllerTest.java
@@ -0,0 +1,151 @@
+package com.bx.implatform.controller.third;
+
+import com.bx.implatform.config.props.JwtProperties;
+import com.bx.implatform.dto.third.user.ThirdCreateOrUpdateUserDTO;
+import com.bx.implatform.enums.ResultCode;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.exception.GlobalExceptionHandler;
+import com.bx.implatform.filter.CacheFilter;
+import com.bx.implatform.interceptor.AuthInterceptor;
+import com.bx.implatform.service.third.ThirdAuthService;
+import com.bx.implatform.service.third.ThirdUserService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@ExtendWith(MockitoExtension.class)
+class ThirdUserControllerTest {
+
+ private static final String URL = "/third/user/createOrUpdate";
+ private static final String VALID_BODY = """
+ {"userId":-100,"userName":"message-notification","phone":"15555555555","nickName":"消息通知"}
+ """;
+
+ @Mock
+ private ThirdUserService service;
+ @Mock
+ private ThirdAuthService authService;
+
+ private MockMvc mvc;
+ private boolean validSignature = true;
+
+ @BeforeEach
+ void setUp() {
+ when(authService.checkSign(any(HttpServletRequest.class))).thenAnswer(ignored -> validSignature);
+ mvc = MockMvcBuilders.standaloneSetup(new ThirdUserController(service))
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .addInterceptors(new AuthInterceptor(new JwtProperties(), authService))
+ .addFilters(new CacheFilter())
+ .build();
+ }
+
+ @Test
+ void successUsesExistingJsonResponseContract() throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.message").value("成功"))
+ .andExpect(jsonPath("$.data").isEmpty());
+
+ verify(service).createOrUpdate(any(ThirdCreateOrUpdateUserDTO.class));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", " ", "\t", "\n"})
+ void blankUsernameReturnsJsonValidationError(String username) throws Exception {
+ String body = new ObjectMapper().writeValueAsString(
+ Map.of("userId", -100, "userName", username, "phone", "15555555555"));
+
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value("用户名不可为空"));
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void missingUserIdReturnsJsonValidationError() throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON)
+ .content("{\"userName\":\"message\",\"phone\":\"15555555555\"}"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.message").value("用户id不可为空"));
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void blankPhoneReturnsJsonValidationError() throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON)
+ .content(VALID_BODY.replace("15555555555", " ")))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.message").value("用户手机号不可为空"));
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void malformedRequestReturnsJsonError() throws Exception {
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{invalid"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(500));
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void unexpectedServiceFailureReturnsJsonWithoutExposingInternalError() throws Exception {
+ doThrow(new IllegalStateException("internal database failure"))
+ .when(service).createOrUpdate(any(ThirdCreateOrUpdateUserDTO.class));
+
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(500))
+ .andExpect(jsonPath("$.message").value(ResultCode.PROGRAM_ERROR.getMsg()));
+ }
+
+ @Test
+ void usernameConflictReturnsJsonBusinessError() throws Exception {
+ doThrow(new GlobalException(ResultCode.USERNAME_ALREADY_REGISTER))
+ .when(service).createOrUpdate(any(ThirdCreateOrUpdateUserDTO.class));
+
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(10003));
+ }
+
+ @Test
+ void invalidSignatureReturnsJsonAndDoesNotWriteUser() throws Exception {
+ validSignature = false;
+
+ mvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
+ .andExpect(status().isOk())
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value(401));
+
+ verifyNoInteractions(service);
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/mapper/PrivateConversationMapperMySqlTest.java b/im-platform/src/test/java/com/bx/implatform/mapper/PrivateConversationMapperMySqlTest.java
new file mode 100644
index 0000000..fa1fcc9
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/mapper/PrivateConversationMapperMySqlTest.java
@@ -0,0 +1,391 @@
+package com.bx.implatform.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
+import com.bx.implatform.config.MybatisPlusConfig;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Opt-in real MySQL 5.7/8 verification. Supply IM_TEST_MYSQL_URL, IM_TEST_MYSQL_USER and
+ * IM_TEST_MYSQL_PASSWORD pointing at a disposable localhost instance. Each case creates a
+ * uniquely named test database and drops only that database; existing tables are never modified.
+ */
+class PrivateConversationMapperMySqlTest {
+ private SqlSession session;
+ private PrivateMessageMapper mapper;
+ private String testDatabase;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ String url = System.getenv("IM_TEST_MYSQL_URL");
+ assumeTrue(url != null && !url.isBlank(), "Set IM_TEST_MYSQL_URL to opt in to isolated MySQL tests");
+ assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?"),
+ "MySQL test connections must use an explicit localhost port and database");
+ UnpooledDataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url,
+ System.getenv().getOrDefault("IM_TEST_MYSQL_USER", "root"),
+ System.getenv().getOrDefault("IM_TEST_MYSQL_PASSWORD", ""));
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ configuration.setMapUnderscoreToCamelCase(true);
+ configuration.addInterceptor(new MybatisPlusConfig().mybatisPlusInterceptor());
+ configuration.setEnvironment(new Environment("conversation-test", new JdbcTransactionFactory(), dataSource));
+ try (InputStream xml = Resources.getResourceAsStream("mapper/PrivateMessageMapper.xml")) {
+ new XMLMapperBuilder(xml, configuration, "mapper/PrivateMessageMapper.xml", configuration.getSqlFragments()).parse();
+ }
+ session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession(true);
+ DatabaseMetaData metadata = session.getConnection().getMetaData();
+ assertTrue(metadata.getDatabaseMajorVersion() > 5
+ || (metadata.getDatabaseMajorVersion() == 5 && metadata.getDatabaseMinorVersion() >= 7),
+ "Conversation SQL supports MySQL 5.7 and newer");
+ try (Statement statement = session.getConnection().createStatement()) {
+ String newDatabase = "codex_im_conversation_" + UUID.randomUUID().toString().replace("-", "");
+ statement.execute("CREATE DATABASE " + newDatabase);
+ testDatabase = newDatabase;
+ }
+ session.getConnection().setCatalog(testDatabase);
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("CREATE TABLE im_user (id BIGINT PRIMARY KEY, user_name VARCHAR(255), "
+ + "nick_name VARCHAR(255), head_image VARCHAR(255), head_image_thumb VARCHAR(255))");
+ statement.execute("CREATE TABLE im_private_message (id BIGINT PRIMARY KEY, "
+ + "send_id BIGINT NOT NULL, recv_id BIGINT NOT NULL, content TEXT, type INT, status INT, "
+ + "send_time DATETIME(3), INDEX idx_pair(send_id,recv_id,send_time,id))");
+ statement.execute("INSERT INTO im_user VALUES "
+ + "(1,'alice-account','Alice','alice.png','alice-thumb.png'),"
+ + "(2,'bob-account','Bob','bob.png',''),(3,'carol-account','Carol','carol.png',NULL)");
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(100,1,2,'old',0,3,'2026-11-01 09:00:00'),"
+ + "(101,2,1,'same time older id',0,3,'2026-11-03 11:00:00'),"
+ + "(102,1,2,'latest by time and id',1,2,'2026-11-03 11:00:00'),"
+ + "(500,2,1,'higher id but older time',0,3,'2026-11-02 11:00:00'),"
+ + "(200,1,3,'carol outbound',0,3,'2026-11-02 10:00:00'),"
+ + "(201,3,1,'carol inbound',0,3,'2026-11-02 11:00:00'),"
+ + "(300,2,9,'missing user at day end',0,3,'2026-11-01 23:59:59.999')");
+ }
+ mapper = session.getMapper(PrivateMessageMapper.class);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (session != null) {
+ try {
+ if (testDatabase != null) {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("DROP DATABASE " + testDatabase);
+ }
+ }
+ } finally {
+ session.close();
+ }
+ }
+ }
+
+ @Test
+ void mergesDirectionsAndSelectsNewestByTimeThenIdRatherThanLargestId() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ assertEquals(3, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(3, rows.size());
+ ThirdPrivateConversationVO first = rows.get(0);
+ assertEquals(1L, first.getUserA().getId());
+ assertEquals(2L, first.getUserB().getId());
+ assertEquals(4L, first.getMessageCount());
+ assertEquals(102L, first.getLastMessage().getId());
+ assertEquals("latest by time and id", first.getLastMessage().getContent());
+ assertEquals(1L, first.getLastMessage().getType());
+ assertEquals(2L, first.getLastMessage().getStatus());
+ assertEquals("Alice", first.getUserA().getNickname());
+ assertEquals("alice-account", first.getUserA().getUsername());
+ }
+
+ @Test
+ void pagesConversationsWithoutDroppingOrDuplicatingPairs() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setPageSize(1);
+ assertEquals(3, mapper.countConversations(dto));
+ assertEquals(102L, mapper.selectConversations(dto, 0L).get(0).getLastMessage().getId());
+ assertEquals(201L, mapper.selectConversations(dto, 1L).get(0).getLastMessage().getId());
+ assertEquals(300L, mapper.selectConversations(dto, 2L).get(0).getLastMessage().getId());
+ assertTrue(mapper.selectConversations(dto, 3L).isEmpty());
+ }
+
+ @Test
+ void equalLatestTimesUseMessageIdForStablePaginationAcrossPairs() throws Exception {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(501,3,4,'same time lower id',0,3,'2026-11-03 11:00:00'),"
+ + "(502,4,3,'same time higher id',0,3,'2026-11-03 11:00:00'),"
+ + "(503,5,6,'same time newest pair',0,3,'2026-11-03 11:00:00')");
+ }
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setPageSize(1);
+ assertEquals(5, mapper.countConversations(dto));
+ long[] expectedIds = {503L, 502L, 102L, 201L, 300L};
+ for (int offset = 0; offset < expectedIds.length; offset++) {
+ List rows = mapper.selectConversations(dto, offset);
+ assertEquals(1, rows.size());
+ assertEquals(expectedIds[offset], rows.get(0).getLastMessage().getId());
+ }
+ assertTrue(mapper.selectConversations(dto, expectedIds.length).isEmpty());
+ }
+
+ @Test
+ void allNullTimesRetainPairAndUseLargestIdAfterDatedConversations() throws Exception {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(400,4,5,'null time lower id',0,3,NULL),"
+ + "(401,5,4,'null time higher id',0,3,NULL)");
+ }
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ assertEquals(4, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(4, rows.size());
+ ThirdPrivateConversationVO nullTimePair = rows.get(3);
+ assertEquals(4L, nullTimePair.getUserA().getId());
+ assertEquals(5L, nullTimePair.getUserB().getId());
+ assertEquals(2L, nullTimePair.getMessageCount());
+ assertEquals(401L, nullTimePair.getLastMessage().getId());
+ assertNull(nullTimePair.getLastMessage().getSendTime());
+
+ dto.setUserKeyword("4");
+ dto.setPeerKeyword("5");
+ assertEquals(1, mapper.countConversations(dto));
+ assertEquals(1, mapper.selectConversations(dto, 0L).size());
+ dto.setLastSendTimeList(day("2026-11-01"));
+ assertEquals(0, mapper.countConversations(dto));
+ assertTrue(mapper.selectConversations(dto, 0L).isEmpty());
+ }
+
+ @Test
+ void datedMessageWinsOverNullTimesEvenWithLargerNullMessageId() throws Exception {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(600,1,4,'null time lower id',0,3,NULL),"
+ + "(601,4,1,'dated latest message',0,3,'2026-11-01 12:00:00'),"
+ + "(602,1,4,'null time higher id',0,3,NULL)");
+ }
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword("4");
+ dto.setPeerKeyword("1");
+ assertEquals(1, mapper.countConversations(dto));
+ ThirdPrivateConversationVO row = mapper.selectConversations(dto, 0L).get(0);
+ assertEquals(3L, row.getMessageCount());
+ assertEquals(601L, row.getLastMessage().getId());
+ assertEquals(Timestamp.valueOf("2026-11-01 12:00:00").getTime(),
+ row.getLastMessage().getSendTime().getTime());
+ dto.setLastSendTimeList(day("2026-11-01"));
+ assertEquals(1, mapper.countConversations(dto));
+ assertEquals(3L, mapper.selectConversations(dto, 0L).get(0).getMessageCount());
+ }
+
+ @Test
+ void missingDirectionRetainsMinimumNegativeAndZeroMessageIds() throws Exception {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(-9223372036854775808,4,5,'minimum signed id',0,3,'2026-11-03 12:00:00'),"
+ + "(-11,7,6,'older negative id',0,3,'2026-11-03 11:00:00'),"
+ + "(-10,7,6,'latest negative id',0,3,'2026-11-03 12:00:00'),"
+ + "(0,9,8,'zero id',0,3,'2026-11-03 12:00:00')");
+ }
+ long[][] expectedPairs = {{4L, 5L, Long.MIN_VALUE, 1L}, {6L, 7L, -10L, 2L}, {8L, 9L, 0L, 1L}};
+ for (long[] expected : expectedPairs) {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword(Long.toString(expected[0]));
+ dto.setPeerKeyword(Long.toString(expected[1]));
+ assertEquals(1, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(1, rows.size());
+ assertEquals(expected[2], rows.get(0).getLastMessage().getId());
+ assertEquals(expected[3], rows.get(0).getMessageCount());
+ }
+ }
+
+ @Test
+ void participantFiltersAreSymmetricAndKeepBothDirectionsInCount() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword("Alice");
+ dto.setPeerKeyword("bob-account");
+ assertEquals(1, mapper.countConversations(dto));
+ assertEquals(4L, mapper.selectConversations(dto, 0L).get(0).getMessageCount());
+ dto.setUserKeyword("bob-account");
+ dto.setPeerKeyword("Alice");
+ assertEquals(1, mapper.countConversations(dto));
+ assertEquals(4L, mapper.selectConversations(dto, 0L).get(0).getMessageCount());
+ dto.setUserKeyword("Alice");
+ dto.setPeerKeyword("Alice");
+ assertEquals(0, mapper.countConversations(dto));
+ }
+
+ @Test
+ void unmatchedParticipantReturnsEmptyAndNumericIdMatchesExactly() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword("no-such-person");
+ assertEquals(0, mapper.countConversations(dto));
+ assertTrue(mapper.selectConversations(dto, 0L).isEmpty());
+ dto.setUserKeyword("2");
+ assertEquals(2, mapper.countConversations(dto));
+ dto.setUserKeyword("20");
+ assertEquals(0, mapper.countConversations(dto));
+ }
+
+ @Test
+ void recentDateFiltersAfterAggregationInsteadOfSelectingOlderMessageInRange() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setLastSendTimeList(day("2026-11-02"));
+ assertEquals(1, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(1, rows.size());
+ assertEquals(3L, rows.get(0).getUserB().getId());
+ assertEquals(2L, rows.get(0).getMessageCount());
+ }
+
+ @Test
+ void retainsMissingProfileAndIncludesEntireSelectedDay() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword("9");
+ dto.setLastSendTimeList(day("2026-11-01"));
+ assertEquals(1, mapper.countConversations(dto));
+ ThirdPrivateConversationVO row = mapper.selectConversations(dto, 0L).get(0);
+ assertEquals(2L, row.getUserA().getId());
+ assertEquals(9L, row.getUserB().getId());
+ assertEquals(300L, row.getLastMessage().getId());
+ }
+
+ @Test
+ void equalMessageCountsDoNotMergeDifferentConversationsInResultMapping() throws Exception {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("INSERT INTO im_private_message VALUES "
+ + "(400,1,8,'first one-message pair',0,3,'2026-11-01 20:00:00'),"
+ + "(401,2,8,'second one-message pair',0,3,'2026-11-01 21:00:00')");
+ }
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ assertEquals(5, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(5, rows.size());
+ assertEquals(5, rows.stream().map(row -> row.getLastMessage().getId()).distinct().count());
+ assertEquals(3, rows.stream().filter(row -> row.getMessageCount() == 1L).count());
+ }
+
+ @Test
+ void indexMigrationCanRunTwiceWithoutChangingMessageRows() throws Exception {
+ executeIndexMigration();
+ executeIndexMigration();
+ try (Statement statement = session.getConnection().createStatement();
+ ResultSet result = statement.executeQuery("SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) "
+ + "FROM information_schema.statistics WHERE table_schema=DATABASE() "
+ + "AND table_name='im_private_message' AND index_name='idx_private_pair_time'")) {
+ assertTrue(result.next());
+ assertEquals("send_id,recv_id,send_time,id", result.getString(1));
+ }
+ try (Statement statement = session.getConnection().createStatement();
+ ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM im_private_message")) {
+ assertTrue(result.next());
+ assertEquals(7, result.getLong(1));
+ }
+ }
+
+ @Test
+ void recordsRepresentativeExplainForTenThousandMessages() throws Exception {
+ executeIndexMigration();
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("ALTER TABLE im_private_message DROP INDEX idx_pair");
+ String digits = "(SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 "
+ + "UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 "
+ + "UNION ALL SELECT 8 UNION ALL SELECT 9)";
+ statement.execute("INSERT INTO im_private_message (id,send_id,recv_id,content,type,status,send_time) "
+ + "SELECT 10000+n,1000+MOD(n,400),2000+MOD(n,400),REPEAT('fixture',20),0,3,"
+ + "DATE_ADD('2026-11-02 00:00:00',INTERVAL n SECOND) FROM "
+ + "(SELECT a.n+b.n*10+c.n*100+d.n*1000 n FROM " + digits + " a CROSS JOIN "
+ + digits + " b CROSS JOIN " + digits + " c CROSS JOIN " + digits + " d) numbers");
+ statement.execute("ANALYZE TABLE im_private_message");
+ }
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ assertEquals(403, mapper.countConversations(dto));
+ assertEquals(dto.getPageSize(), mapper.selectConversations(dto, 0L).size());
+ MappedStatement mapped = session.getConfiguration().getMappedStatement(
+ PrivateMessageMapper.class.getName() + ".selectConversations");
+ Map parameters = Map.of("dto", dto, "offset", 0L);
+ BoundSql boundSql = mapped.getBoundSql(parameters);
+ try (PreparedStatement statement = session.getConnection().prepareStatement("EXPLAIN " + boundSql.getSql())) {
+ new DefaultParameterHandler(mapped, parameters, boundSql).setParameters(statement);
+ try (ResultSet result = statement.executeQuery()) {
+ StringBuilder plan = new StringBuilder();
+ int indexedLatestLookups = 0;
+ while (result.next()) {
+ String table = result.getString("table");
+ plan.append(result.getString("select_type")).append(" table=")
+ .append(table).append(" type=")
+ .append(result.getString("type")).append(" key=")
+ .append(result.getString("key")).append(" rows=")
+ .append(result.getString("rows")).append(" extra=")
+ .append(result.getString("Extra")).append('\n');
+ if ("latest_forward".equals(table) || "latest_reverse".equals(table)) {
+ assertEquals("idx_private_pair_time", result.getString("key"),
+ table + " must use the pair/time index");
+ assertNotEquals("ALL", result.getString("type"),
+ table + " must not scan all messages for each conversation");
+ indexedLatestLookups++;
+ }
+ }
+ assertFalse(plan.toString().isBlank());
+ assertEquals(2, indexedLatestLookups, "Both directions must have indexed latest-message lookups");
+ System.out.println("Conversation EXPLAIN (10,007 synthetic messages, not a production benchmark):\n" + plan);
+ }
+ }
+ try (Statement statement = session.getConnection().createStatement();
+ ResultSet result = statement.executeQuery("EXPLAIN SELECT * FROM im_private_message "
+ + "WHERE (send_id=1 AND recv_id=2) OR (send_id=2 AND recv_id=1) "
+ + "ORDER BY send_time DESC,id DESC LIMIT 10")) {
+ assertTrue(result.next());
+ assertEquals("idx_private_pair_time", result.getString("key"));
+ System.out.println("Bilateral detail EXPLAIN: key=" + result.getString("key")
+ + " type=" + result.getString("type") + " extra=" + result.getString("Extra"));
+ }
+ }
+
+ private void executeIndexMigration() throws Exception {
+ Path script = Path.of("db", "20261103_private_conversation_index.sql");
+ if (!Files.exists(script)) {
+ script = Path.of("..", "db", "20261103_private_conversation_index.sql");
+ }
+ String sql = Files.readString(script).replaceAll("(?m)^--.*$", "");
+ try (Statement statement = session.getConnection().createStatement()) {
+ for (String command : sql.split(";")) {
+ if (!command.isBlank()) {
+ statement.execute(command);
+ }
+ }
+ }
+ }
+
+ private List day(String day) {
+ return List.of(Timestamp.valueOf(day + " 00:00:00"), Timestamp.valueOf(day + " 23:59:59.999"));
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/mapper/PrivateMessageSessionControlMySqlTest.java b/im-platform/src/test/java/com/bx/implatform/mapper/PrivateMessageSessionControlMySqlTest.java
new file mode 100644
index 0000000..4aedf15
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/mapper/PrivateMessageSessionControlMySqlTest.java
@@ -0,0 +1,172 @@
+package com.bx.implatform.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.bx.implatform.config.MybatisPlusConfig;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.util.PrivateMessageSessionControl;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.LongStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/** Real MySQL 5.7/8 predicates, using only a fresh disposable localhost schema per test. */
+class PrivateMessageSessionControlMySqlTest {
+ private SqlSession session;
+ private PrivateMessageMapper mapper;
+ private String testDatabase;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ String url = System.getenv("IM_TEST_MYSQL_URL");
+ assumeTrue(url != null && !url.isBlank(), "Set IM_TEST_MYSQL_URL for isolated MySQL verification");
+ assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?"),
+ "MySQL tests require an explicit localhost port and database");
+ UnpooledDataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url,
+ System.getenv().getOrDefault("IM_TEST_MYSQL_USER", "root"),
+ System.getenv().getOrDefault("IM_TEST_MYSQL_PASSWORD", ""));
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ configuration.setMapUnderscoreToCamelCase(true);
+ configuration.addInterceptor(new MybatisPlusConfig().mybatisPlusInterceptor());
+ configuration.setEnvironment(new Environment("session-control-test", new JdbcTransactionFactory(), dataSource));
+ try (InputStream xml = Resources.getResourceAsStream("mapper/PrivateMessageMapper.xml")) {
+ new XMLMapperBuilder(xml, configuration, "mapper/PrivateMessageMapper.xml", configuration.getSqlFragments()).parse();
+ }
+ session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession(true);
+ DatabaseMetaData metadata = session.getConnection().getMetaData();
+ assertTrue(metadata.getDatabaseMajorVersion() > 5
+ || (metadata.getDatabaseMajorVersion() == 5 && metadata.getDatabaseMinorVersion() >= 7),
+ "Session-control history predicates support MySQL 5.7 and newer");
+ try (Statement statement = session.getConnection().createStatement()) {
+ String newDatabase = "codex_im_session_control_" + UUID.randomUUID().toString().replace("-", "");
+ statement.execute("CREATE DATABASE " + newDatabase + " CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
+ testDatabase = newDatabase;
+ }
+ session.getConnection().setCatalog(testDatabase);
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("CREATE TABLE im_user (id BIGINT PRIMARY KEY, user_name VARCHAR(255), "
+ + "nick_name VARCHAR(255), head_image VARCHAR(255), head_image_thumb VARCHAR(255))");
+ // Nullable legacy values exercise SQL three-valued logic instead of losing unrelated messages.
+ statement.execute("CREATE TABLE im_private_message (id BIGINT PRIMARY KEY, tmp_id VARCHAR(32), "
+ + "send_id BIGINT, recv_id BIGINT, content TEXT, type INT, status INT, send_time DATETIME(3))");
+ }
+ List fixtures = List.of(
+ new Fixture(1, -100L, 0, "ordinary message"),
+ new Fixture(2, -100L, 0, null),
+ new Fixture(3, -100L, 0, "xxAPP_KICK_SESSION__:underscore wildcard lookalike"),
+ new Fixture(4, -100L, 0, "__APPxKICK_SESSION__:underscore wildcard lookalike"),
+ new Fixture(5, -100L, 0, "__APP_KICKxSESSION__:underscore wildcard lookalike"),
+ new Fixture(6, -100L, 0, "__APP_KICK_SESSIONxx:underscore wildcard lookalike"),
+ new Fixture(7, -100L, 0, "__app_kick_session__:different case"),
+ new Fixture(8, -100L, 0, " __APP_KICK_SESSION__:leading space"),
+ new Fixture(9, -100L, 0, "__APP_KICK_SESSION__"),
+ new Fixture(10, 7L, 0, "__APP_KICK_SESSION__:ordinary sender"),
+ new Fixture(11, -100L, 1, "__APP_KICK_SESSION__:different message type"),
+ new Fixture(12, null, 0, "__APP_KICK_SESSION__:unknown sender"),
+ new Fixture(13, -100L, null, "__APP_KICK_SESSION__:unknown type"),
+ new Fixture(14, null, null, null),
+ new Fixture(15, -101L, 0, "__APP_KICK_SESSION__:other system sender"),
+ new Fixture(101, -100L, 0, "__APP_KICK_SESSION__:banned"),
+ new Fixture(102, -100L, 0, "__APP_KICK_SESSION__:"),
+ new Fixture(103, -100L, 0, "__APP_KICK_SESSION__:line one\nline two"));
+ try (PreparedStatement statement = session.getConnection().prepareStatement(
+ "INSERT INTO im_private_message (id,send_id,recv_id,content,type,status,send_time) "
+ + "VALUES (?,?,42,?,?,3,'2026-11-03 12:00:00')")) {
+ for (Fixture fixture : fixtures) {
+ statement.setLong(1, fixture.id());
+ statement.setObject(2, fixture.sendId());
+ statement.setString(3, fixture.content());
+ statement.setObject(4, fixture.type());
+ statement.addBatch();
+ }
+ statement.executeBatch();
+ }
+ mapper = session.getMapper(PrivateMessageMapper.class);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (session != null) {
+ try {
+ if (testDatabase != null) {
+ try (Statement statement = session.getConnection().createStatement()) {
+ statement.execute("DROP DATABASE " + testDatabase);
+ }
+ }
+ } finally {
+ session.close();
+ }
+ }
+ }
+
+ @Test
+ void lambdaHistoryExcludesControlsBeforeApplyingLimit() {
+ LambdaQueryWrapper query = new LambdaQueryWrapper()
+ .eq(PrivateMessage::getRecvId, 42L).orderByDesc(PrivateMessage::getId).last("LIMIT 3");
+ PrivateMessageSessionControl.applyHistoryFilter(query);
+ assertEquals(List.of(15L, 14L, 13L), ids(mapper.selectList(query)));
+ }
+
+ @Test
+ void stringColumnHistoryExcludesControlsBeforeApplyingLimit() {
+ QueryWrapper query = new QueryWrapper()
+ .eq("recv_id", 42L).orderByDesc("id").last("LIMIT 3");
+ PrivateMessageSessionControl.applyHistoryFilter(query);
+ assertEquals(List.of(15L, 14L, 13L), ids(mapper.selectList(query)));
+ }
+
+ @Test
+ void bothPredicatesRetainNullsOrdinaryMessagesAndLiteralPrefixLookalikes() {
+ List expected = LongStream.rangeClosed(1, 15).boxed().toList();
+ LambdaQueryWrapper lambda = new LambdaQueryWrapper()
+ .eq(PrivateMessage::getRecvId, 42L).orderByAsc(PrivateMessage::getId);
+ PrivateMessageSessionControl.applyHistoryFilter(lambda);
+ assertEquals(expected, ids(mapper.selectList(lambda)));
+
+ QueryWrapper columns = new QueryWrapper()
+ .eq("recv_id", 42L).orderByAsc("id");
+ PrivateMessageSessionControl.applyHistoryFilter(columns);
+ assertEquals(expected, ids(mapper.selectList(columns)));
+ }
+
+ @Test
+ void adminQueriesKeepControlContentAndFullConversationCount() {
+ assertEquals(18, mapper.selectList(new QueryWrapper().eq("recv_id", 42L)).size());
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setUserKeyword("-100");
+ dto.setPeerKeyword("42");
+ assertEquals(1, mapper.countConversations(dto));
+ List rows = mapper.selectConversations(dto, 0L);
+ assertEquals(1, rows.size());
+ assertEquals(14L, rows.get(0).getMessageCount());
+ assertEquals(103L, rows.get(0).getLastMessage().getId());
+ assertEquals("__APP_KICK_SESSION__:line one\nline two", rows.get(0).getLastMessage().getContent());
+ }
+
+ private List ids(List messages) {
+ return messages.stream().map(PrivateMessage::getId).toList();
+ }
+
+ private record Fixture(long id, Long sendId, Integer type, String content) {
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationMySqlTest.java b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationMySqlTest.java
new file mode 100644
index 0000000..39f21f7
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationMySqlTest.java
@@ -0,0 +1,202 @@
+package com.bx.implatform.service;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.config.GlobalConfig;
+import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
+import com.bx.imclient.IMClient;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageModerateDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.service.impl.PrivateMessageServiceImpl;
+import com.bx.implatform.util.SensitiveFilterUtil;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageModerateVO;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.sql.Connection;
+import java.sql.Statement;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/** Real row locks and transaction rollback, in a fresh localhost-only schema per test. */
+class PrivateMessageModerationMySqlTest {
+ private UnpooledDataSource adminDataSource;
+ private String testDatabase;
+ private JdbcTemplate jdbc;
+ private PrivateMessageMapper mapper;
+ private PrivateMessageModerationService service;
+ private IMClient client;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ String url = System.getenv("IM_TEST_MYSQL_URL");
+ assumeTrue(url != null && !url.isBlank(), "Set IM_TEST_MYSQL_URL for isolated MySQL verification");
+ assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?"));
+ String user = System.getenv().getOrDefault("IM_TEST_MYSQL_USER", "root");
+ String password = System.getenv().getOrDefault("IM_TEST_MYSQL_PASSWORD", "");
+ adminDataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, user, password);
+ String newDatabase = "codex_im_moderation_" + UUID.randomUUID().toString().replace("-", "");
+ try (Connection connection = adminDataSource.getConnection(); Statement statement = connection.createStatement()) {
+ assertTrue(connection.getMetaData().getDatabaseMajorVersion() >= 8);
+ statement.execute("CREATE DATABASE " + newDatabase);
+ testDatabase = newDatabase;
+ }
+ String testUrl = url.replaceFirst("/[a-zA-Z0-9_]+(?=\\?|$)", "/" + testDatabase);
+ UnpooledDataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", testUrl, user, password);
+ jdbc = new JdbcTemplate(dataSource);
+ jdbc.execute("CREATE TABLE im_private_message (id BIGINT AUTO_INCREMENT PRIMARY KEY, tmp_id VARCHAR(32), "
+ + "send_id BIGINT NOT NULL, recv_id BIGINT NOT NULL, content TEXT, type INT NOT NULL, status INT NOT NULL, "
+ + "send_time DATETIME(3), INDEX idx_private_pair_time(send_id,recv_id,send_time,id)) ENGINE=InnoDB");
+ jdbc.update("INSERT INTO im_private_message VALUES (101,'ordinary-client-id',7,42,'original content',0,1,NOW(3))");
+
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ configuration.setMapUnderscoreToCamelCase(true);
+ configuration.setEnvironment(new Environment("moderation-test", new SpringManagedTransactionFactory(), dataSource));
+ GlobalConfig globalConfig = new GlobalConfig();
+ globalConfig.setDbConfig(new GlobalConfig.DbConfig().setIdType(IdType.AUTO));
+ GlobalConfigUtils.setGlobalConfig(configuration, globalConfig);
+ configuration.addMapper(PrivateMessageMapper.class);
+ SqlSessionFactory factory = new MybatisSqlSessionFactoryBuilder().build(configuration);
+ mapper = new SqlSessionTemplate(factory).getMapper(PrivateMessageMapper.class);
+ client = mock(IMClient.class);
+ service = new PrivateMessageModerationService(mapper, client,
+ new TransactionTemplate(new DataSourceTransactionManager(dataSource)));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (testDatabase != null) {
+ try (Connection connection = adminDataSource.getConnection(); Statement statement = connection.createStatement()) {
+ statement.execute("DROP DATABASE " + testDatabase);
+ }
+ }
+ }
+
+ @Test
+ void concurrentRequestsLockSameSourceAndCreateOnlyOneCommittedRecall() throws Exception {
+ doAnswer(invocation -> {
+ // JdbcTemplate outside the completed service transaction observes committed rows.
+ assertEquals(2, jdbc.queryForObject("SELECT status FROM im_private_message WHERE id=101", Integer.class));
+ assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM im_private_message WHERE tmp_id='admin-m:101'", Integer.class));
+ return null;
+ }).when(client).sendPrivateMessage(any());
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ CountDownLatch ready = new CountDownLatch(2);
+ CountDownLatch start = new CountDownLatch(1);
+ try {
+ java.util.concurrent.Callable task = () -> {
+ ready.countDown();
+ assertTrue(start.await(5, TimeUnit.SECONDS));
+ return service.moderate(request());
+ };
+ Future first = executor.submit(task);
+ Future second = executor.submit(task);
+ assertTrue(ready.await(5, TimeUnit.SECONDS));
+ start.countDown();
+ ThirdPrivateMessageModerateVO a = first.get(15, TimeUnit.SECONDS);
+ ThirdPrivateMessageModerateVO b = second.get(15, TimeUnit.SECONDS);
+ assertEquals(a.getRecallMessageId(), b.getRecallMessageId());
+ assertNotEquals(a.getAlreadyHidden(), b.getAlreadyHidden());
+ assertTrue(a.getNotificationSent());
+ assertTrue(b.getNotificationSent());
+ assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM im_private_message", Integer.class));
+ assertEquals("original content", jdbc.queryForObject("SELECT content FROM im_private_message WHERE id=101", String.class));
+ verify(client, times(4)).sendPrivateMessage(any());
+ } finally {
+ executor.shutdownNow();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void databaseFailureRollsBackSourceStatusAndCreatesNoRecall() {
+ jdbc.execute("ALTER TABLE im_private_message ADD CONSTRAINT no_recall_fixture CHECK(type <> 10)");
+ assertThrows(RuntimeException.class, () -> service.moderate(request()));
+ assertEquals(1, jdbc.queryForObject("SELECT status FROM im_private_message WHERE id=101", Integer.class));
+ assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM im_private_message", Integer.class));
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void notificationFailureRetainsDurableTombstoneAndCanBeRetried() {
+ doThrow(new IllegalStateException("transport unavailable")).when(client).sendPrivateMessage(any());
+ ThirdPrivateMessageModerateVO first = service.moderate(request());
+ assertFalse(first.getNotificationSent());
+ assertEquals(2, jdbc.queryForObject("SELECT status FROM im_private_message WHERE id=101", Integer.class));
+ reset(client);
+ ThirdPrivateMessageModerateVO retry = service.moderate(request());
+ assertTrue(retry.getAlreadyHidden());
+ assertTrue(retry.getNotificationSent());
+ assertEquals(first.getRecallMessageId(), retry.getRecallMessageId());
+ assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM im_private_message WHERE type=10", Integer.class));
+ }
+
+ @Test
+ void offlineQueryReplaysOldAdminRecallBeyondCursorWithoutExposingOriginalOrOtherUsers() {
+ ThirdPrivateMessageModerateVO result = service.moderate(request());
+ jdbc.update("UPDATE im_private_message SET send_time='2020-01-01 00:00:00' WHERE id=?", result.getRecallMessageId());
+ jdbc.update("INSERT INTO im_private_message (tmp_id,send_id,recv_id,content,type,status,send_time) VALUES "
+ + "('admin-m:500',8,9,'500',10,3,'2020-01-01'),"
+ + "('ADMIN-M:501',7,42,'501',10,3,'2020-01-01'),"
+ + "('normal-recall',7,42,'502',10,3,'2020-01-01')");
+ PrivateMessageServiceImpl history = new PrivateMessageServiceImpl(mock(FriendService.class), client,
+ mock(SensitiveFilterUtil.class), service);
+ LambdaQueryWrapper query = ReflectionTestUtils.invokeMethod(history, "buildOfflineQuery", 42L, 999999L);
+ List messages = mapper.selectList(query);
+ assertEquals(1, messages.size());
+ assertEquals(result.getRecallMessageId(), messages.get(0).getId());
+ assertEquals(101L, service.toMobileVO(messages.get(0)).getId());
+ assertEquals("101", service.toMobileVO(messages.get(0)).getContent());
+ assertEquals(3, service.toMobileVO(messages.get(0)).getStatus());
+ }
+
+ @Test
+ void staleOfflineSnapshotIsRecheckedAfterModeration() {
+ List stale = mapper.selectList(null);
+ assertEquals("original content", stale.get(0).getContent());
+ service.moderate(request());
+ assertTrue(service.refreshVisibleMessages(stale).isEmpty());
+ }
+
+ @Test
+ void caseInsensitiveMarkerLookalikeCannotBlockOrReplaceAdministratorRecall() {
+ jdbc.update("INSERT INTO im_private_message (tmp_id,send_id,recv_id,content,type,status,send_time) "
+ + "VALUES ('ADMIN-M:101',7,42,'101',10,3,NOW(3))");
+ ThirdPrivateMessageModerateVO receipt = service.moderate(request());
+ assertTrue(receipt.getNotificationSent());
+ assertEquals("admin-m:101", jdbc.queryForObject("SELECT tmp_id FROM im_private_message WHERE id=?",
+ String.class, receipt.getRecallMessageId()));
+ assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM im_private_message WHERE type=10", Integer.class));
+ }
+
+ private ThirdPrivateMessageModerateDTO request() {
+ ThirdPrivateMessageModerateDTO dto = new ThirdPrivateMessageModerateDTO();
+ dto.setMessageId(101L);
+ dto.setOperatorId(42L);
+ dto.setReason("integration fixture");
+ return dto;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationReadPathTest.java b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationReadPathTest.java
new file mode 100644
index 0000000..1baa573
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationReadPathTest.java
@@ -0,0 +1,131 @@
+package com.bx.implatform.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.bx.imclient.IMClient;
+import com.bx.imcommon.enums.IMSendCode;
+import com.bx.imcommon.model.IMSendResult;
+import com.bx.imcommon.model.IMUserInfo;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.listener.PrivateMessageListener;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.service.impl.PrivateMessageServiceImpl;
+import com.bx.implatform.session.UserSession;
+import com.bx.implatform.util.SensitiveFilterUtil;
+import com.bx.implatform.vo.PrivateMessageVO;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class PrivateMessageModerationReadPathTest {
+ private PrivateMessageMapper mapper;
+ private PrivateMessageModerationService moderation;
+ private PrivateMessageServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(PrivateMessageMapper.class);
+ moderation = mock(PrivateMessageModerationService.class);
+ service = new PrivateMessageServiceImpl(mock(FriendService.class), mock(IMClient.class),
+ mock(SensitiveFilterUtil.class), moderation);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "moderation-read-test"), PrivateMessage.class);
+ UserSession session = new UserSession();
+ session.setUserId(42L);
+ session.setTerminal(1);
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setAttribute("session", session);
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ }
+
+ @AfterEach
+ void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ void pendingOfflineAcknowledgementCannotOverwriteConcurrentlyHiddenStatus() {
+ PrivateMessage snapshot = new PrivateMessage();
+ snapshot.setId(101L);
+ snapshot.setRecvId(42L);
+ snapshot.setStatus(0);
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(snapshot));
+ // The re-read observes that moderation has hidden the source since the initial query.
+ when(moderation.refreshVisibleMessages(anyList())).thenReturn(Collections.emptyList());
+
+ assertTrue(service.loadOfflineMessage(0L).isEmpty());
+
+ ArgumentCaptor> update = ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).update(isNull(), update.capture());
+ String predicate = update.getValue().getSqlSegment();
+ assertTrue(predicate.contains("status ="), predicate);
+ assertTrue(((com.baomidou.mybatisplus.core.conditions.AbstractWrapper) update.getValue())
+ .getParamNameValuePairs().containsValue(0));
+ InOrder order = inOrder(mapper, moderation);
+ order.verify(mapper).selectList(any(Wrapper.class));
+ order.verify(mapper).update(isNull(), any(Wrapper.class));
+ order.verify(moderation).refreshVisibleMessages(anyList());
+ }
+
+ @Test
+ void regularUserCannotRecallAdministratorCompensationRecord() {
+ PrivateMessage event = new PrivateMessage();
+ event.setId(202L);
+ event.setType(10);
+ event.setContent("101");
+ event.setTmpId("admin-m:101");
+ when(mapper.selectById(202L)).thenReturn(event);
+ assertThrows(GlobalException.class, () -> service.recallMessage(202L));
+ verify(mapper, never()).updateById(any(PrivateMessage.class));
+ verify(mapper, never()).insert(any(PrivateMessage.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void delayedSourceAcknowledgementsReassertModerationOnceAndIgnoreRecallCallbacks() {
+ PrivateMessageService privateService = mock(PrivateMessageService.class);
+ PrivateMessageListener listener = new PrivateMessageListener();
+ ReflectionTestUtils.setField(listener, "privateMessageService", privateService);
+ ReflectionTestUtils.setField(listener, "moderationService", moderation);
+ IMSendResult source = delivered(101L, 0);
+ listener.process(Arrays.asList(source, source, delivered(101L, 10), delivered(-1L, 0), null));
+
+ InOrder order = inOrder(privateService, moderation);
+ order.verify(privateService).update(any(Wrapper.class));
+ order.verify(moderation).replayIfModerated(Set.of(101L));
+ verifyNoMoreInteractions(moderation);
+ }
+
+ private IMSendResult delivered(Long id, int type) {
+ PrivateMessageVO message = new PrivateMessageVO();
+ message.setId(id);
+ message.setType(type);
+ message.setSendId(7L);
+ message.setRecvId(42L);
+ IMSendResult result = new IMSendResult<>();
+ result.setCode(IMSendCode.SUCCESS.code());
+ result.setSender(new IMUserInfo(7L, 1));
+ result.setReceiver(new IMUserInfo(42L, 1));
+ result.setData(message);
+ return result;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationServiceTest.java b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationServiceTest.java
new file mode 100644
index 0000000..079b1f1
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageModerationServiceTest.java
@@ -0,0 +1,257 @@
+package com.bx.implatform.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.bx.imclient.IMClient;
+import com.bx.imcommon.enums.IMTerminalType;
+import com.bx.imcommon.model.IMPrivateMessage;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageModerateDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.vo.PrivateMessageVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateMessageModerateVO;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.TransactionDefinition;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class PrivateMessageModerationServiceTest {
+ @Mock
+ private PrivateMessageMapper mapper;
+ @Mock
+ private IMClient client;
+ @Mock
+ private PlatformTransactionManager transactionManager;
+ private PrivateMessageModerationService service;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
+ service = new PrivateMessageModerationService(mapper, client, new TransactionTemplate(transactionManager));
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "moderation-test"), PrivateMessage.class);
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void commitsHiddenStatusAndDurableEventBeforeNotifyingBothParticipants() {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(0, 1));
+ when(mapper.updateById(any(PrivateMessage.class))).thenReturn(1);
+ when(mapper.insert(any(PrivateMessage.class))).thenAnswer(invocation -> {
+ ((PrivateMessage) invocation.getArgument(0)).setId(202L);
+ return 1;
+ });
+
+ ThirdPrivateMessageModerateVO result = service.moderate(request());
+
+ assertEquals(101L, result.getMessageId());
+ assertEquals(2, result.getStatus());
+ assertEquals(202L, result.getRecallMessageId());
+ assertFalse(result.getAlreadyHidden());
+ assertTrue(result.getNotificationSent());
+ InOrder order = inOrder(mapper, transactionManager, client);
+ order.verify(transactionManager).getTransaction(any());
+ order.verify(mapper).selectForUpdate(101L);
+ order.verify(mapper).updateById(any(PrivateMessage.class));
+ order.verify(mapper).selectModerationEvent("admin-m:101", 7L, 42L);
+ order.verify(mapper).insert(any(PrivateMessage.class));
+ order.verify(transactionManager).commit(any());
+ order.verify(client, times(2)).sendPrivateMessage(any());
+
+ ArgumentCaptor update = ArgumentCaptor.forClass(PrivateMessage.class);
+ verify(mapper).updateById(update.capture());
+ assertEquals(2, update.getValue().getStatus());
+ assertNull(update.getValue().getContent(), "Moderation must retain the original content in storage");
+ ArgumentCaptor inserted = ArgumentCaptor.forClass(PrivateMessage.class);
+ verify(mapper).insert(inserted.capture());
+ assertEquals("admin-m:101", inserted.getValue().getTmpId());
+ assertEquals("101", inserted.getValue().getContent());
+ assertEquals(10, inserted.getValue().getType());
+ assertEquals(3, inserted.getValue().getStatus());
+ ArgumentCaptor transaction = ArgumentCaptor.forClass(TransactionDefinition.class);
+ verify(transactionManager).getTransaction(transaction.capture());
+ assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transaction.getValue().getPropagationBehavior());
+
+ ArgumentCaptor> pushes = ArgumentCaptor.forClass(IMPrivateMessage.class);
+ verify(client, times(2)).sendPrivateMessage(pushes.capture());
+ assertEquals(List.of(7L, 42L), pushes.getAllValues().stream().map(IMPrivateMessage::getRecvId).toList());
+ for (IMPrivateMessage push : pushes.getAllValues()) {
+ assertEquals(IMTerminalType.codes(), push.getRecvTerminals());
+ assertFalse(push.getSendToSelf());
+ assertFalse(push.getSendResult());
+ assertEquals(101L, push.getData().getId());
+ assertEquals("101", push.getData().getContent());
+ assertEquals(3, push.getData().getStatus());
+ assertEquals(7L, push.getData().getSendId());
+ assertEquals(42L, push.getData().getRecvId());
+ }
+ }
+
+ @Test
+ void repeatModerationReusesStoredEventAndRetriesNotification() {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(0, 2));
+ when(mapper.selectModerationEvent("admin-m:101", 7L, 42L)).thenReturn(event());
+ ThirdPrivateMessageModerateVO result = service.moderate(request());
+ assertTrue(result.getAlreadyHidden());
+ assertTrue(result.getNotificationSent());
+ assertEquals(202L, result.getRecallMessageId());
+ verify(mapper, never()).updateById(any(PrivateMessage.class));
+ verify(mapper, never()).insert(any(PrivateMessage.class));
+ verify(client, times(2)).sendPrivateMessage(any());
+ }
+
+ @Test
+ void notificationFailureDoesNotUndoCommittedModerationOrSkipSecondParticipant() {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(0, 2));
+ when(mapper.selectModerationEvent("admin-m:101", 7L, 42L)).thenReturn(event());
+ doThrow(new IllegalStateException("push unavailable")).doNothing().when(client).sendPrivateMessage(any());
+ ThirdPrivateMessageModerateVO result = service.moderate(request());
+ assertFalse(result.getNotificationSent());
+ assertEquals(2, result.getStatus());
+ verify(transactionManager).commit(any());
+ verify(transactionManager, never()).rollback(any());
+ verify(client, times(2)).sendPrivateMessage(any());
+ }
+
+ @Test
+ void failedEventInsertRollsBackAndNeverNotifies() {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(0, 1));
+ when(mapper.updateById(any(PrivateMessage.class))).thenReturn(1);
+ when(mapper.insert(any(PrivateMessage.class))).thenReturn(0);
+ assertThrows(GlobalException.class, () -> service.moderate(request()));
+ verify(transactionManager).rollback(any());
+ verify(transactionManager, never()).commit(any());
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void failedCommitNeverPublishesAnUncommittedRecall() {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(0, 2));
+ when(mapper.selectModerationEvent("admin-m:101", 7L, 42L)).thenReturn(event());
+ doThrow(new IllegalStateException("commit unavailable")).when(transactionManager).commit(any());
+ assertThrows(IllegalStateException.class, () -> service.moderate(request()));
+ verifyNoInteractions(client);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {-1, 10, 11, 12, 21, 30, 50})
+ void refusesControlMessagesWithoutWritingOrNotifying(int type) {
+ when(mapper.selectForUpdate(101L)).thenReturn(source(type, 1));
+ assertThrows(GlobalException.class, () -> service.moderate(request()));
+ verify(mapper, never()).updateById(any(PrivateMessage.class));
+ verify(mapper, never()).insert(any(PrivateMessage.class));
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void missingSourceCannotCreateAnOrphanRecall() {
+ assertThrows(GlobalException.class, () -> service.moderate(request()));
+ verify(mapper, never()).insert(any(PrivateMessage.class));
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void wireProjectionUsesSourceIdWithoutMutatingStoredEvent() {
+ PrivateMessage event = event();
+ PrivateMessageVO wire = service.toMobileVO(event);
+ assertEquals(101L, wire.getId());
+ assertEquals("101", wire.getContent());
+ assertEquals(3, wire.getStatus());
+ assertEquals(202L, event.getId());
+ assertEquals("admin-m:101", event.getTmpId());
+ }
+
+ @Test
+ void delayedDeliveryReplaysOnlyExistingAdminModerationEvents() {
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(source(0, 2)));
+ when(mapper.selectModerationEvent("admin-m:101", 7L, 42L)).thenReturn(event());
+ service.replayIfModerated(101L);
+ verify(client, times(2)).sendPrivateMessage(any());
+ verify(mapper, never()).insert(any(PrivateMessage.class));
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void ordinaryDeliveryBatchChecksHiddenStatusOnceWithoutFetchingBodiesOrEvents() {
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+ service.replayIfModerated(List.of(101L, 102L));
+ ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectList(query.capture());
+ String selected = query.getValue().getSqlSelect().toLowerCase();
+ assertTrue(selected.contains("id"));
+ assertTrue(selected.contains("status"));
+ assertFalse(selected.contains("content"), selected);
+ assertFalse(selected.contains("tmp_id"), selected);
+ assertTrue(query.getValue().getSqlSegment().toLowerCase().contains("status"));
+ verify(mapper, never()).selectById(anyLong());
+ verify(mapper, never()).selectModerationEvent(anyString(), anyLong(), anyLong());
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ void refreshedOfflineSnapshotDropsHiddenAndDeletedRowsButKeepsRecallEvent() {
+ PrivateMessage stale = source(0, 1);
+ PrivateMessage deleted = source(0, 1);
+ deleted.setId(102L);
+ PrivateMessage current = source(0, 2);
+ when(mapper.selectBatchIds(anyCollection())).thenReturn(List.of(current, event()));
+ List visible = service.refreshVisibleMessages(List.of(stale, deleted, event()));
+ assertEquals(1, visible.size());
+ assertEquals(202L, visible.get(0).getId());
+ assertEquals(10, visible.get(0).getType());
+ }
+
+ @Test
+ void reservedMarkerCannotBeUsedByOrdinarySenders() {
+ assertThrows(GlobalException.class, () -> PrivateMessageModerationService.rejectReservedTmpId("admin-m:101"));
+ assertDoesNotThrow(() -> PrivateMessageModerationService.rejectReservedTmpId("normal-client-id"));
+ assertDoesNotThrow(() -> PrivateMessageModerationService.rejectReservedTmpId(null));
+ }
+
+ private ThirdPrivateMessageModerateDTO request() {
+ ThirdPrivateMessageModerateDTO dto = new ThirdPrivateMessageModerateDTO();
+ dto.setMessageId(101L);
+ dto.setOperatorId(42L);
+ dto.setReason("违规内容");
+ return dto;
+ }
+
+ private PrivateMessage source(int type, int status) {
+ PrivateMessage message = new PrivateMessage();
+ message.setId(101L);
+ message.setSendId(7L);
+ message.setRecvId(42L);
+ message.setType(type);
+ message.setStatus(status);
+ message.setContent("original private content");
+ message.setSendTime(new Date());
+ return message;
+ }
+
+ private PrivateMessage event() {
+ PrivateMessage event = source(10, 3);
+ event.setId(202L);
+ event.setTmpId("admin-m:101");
+ event.setContent("101");
+ return event;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageSessionControlReadPathTest.java b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageSessionControlReadPathTest.java
new file mode 100644
index 0000000..0f80120
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/PrivateMessageSessionControlReadPathTest.java
@@ -0,0 +1,177 @@
+package com.bx.implatform.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.bx.imclient.IMClient;
+import com.bx.imcommon.enums.IMTerminalType;
+import com.bx.imcommon.model.IMPrivateMessage;
+import com.bx.implatform.dto.PrivateMessageDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.service.impl.PrivateMessageServiceImpl;
+import com.bx.implatform.session.UserSession;
+import com.bx.implatform.util.BeanUtils;
+import com.bx.implatform.util.PrivateMessageSessionControl;
+import com.bx.implatform.util.SensitiveFilterUtil;
+import com.bx.implatform.vo.PrivateMessageVO;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class PrivateMessageSessionControlReadPathTest {
+ private PrivateMessageMapper mapper;
+ private PrivateMessageModerationService moderation;
+ private FriendService friends;
+ private IMClient imClient;
+ private PrivateMessageServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(PrivateMessageMapper.class);
+ moderation = mock(PrivateMessageModerationService.class);
+ friends = mock(FriendService.class);
+ imClient = mock(IMClient.class);
+ service = new PrivateMessageServiceImpl(friends, imClient, mock(SensitiveFilterUtil.class), moderation);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "session-control-read-test"),
+ PrivateMessage.class);
+ UserSession session = new UserSession();
+ session.setUserId(42L);
+ session.setTerminal(IMTerminalType.WEB.code());
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setAttribute("session", session);
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ when(moderation.refreshVisibleMessages(anyList())).thenAnswer(call -> call.getArgument(0));
+ when(moderation.toMobileVO(any(PrivateMessage.class))).thenAnswer(call ->
+ BeanUtils.copyProperties(call.getArgument(0), PrivateMessageVO.class));
+ }
+
+ @AfterEach
+ void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ void ordinaryUsersCannotInjectKickProtocolEvenWithNonTextType() {
+ PrivateMessageDTO dto = new PrivateMessageDTO();
+ dto.setRecvId(7L);
+ dto.setType(1);
+ dto.setContent(PrivateMessageSessionControl.KICK_PREFIX + "伪造踢登录");
+
+ assertThrows(GlobalException.class, () -> service.sendMessage(dto));
+
+ verifyNoInteractions(friends, mapper, imClient);
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ void offlineRestDoesNotReplayLegacyDisableKickAfterReenable() {
+ PrivateMessage kick = legacyKick();
+ PrivateMessage normal = message(102L, 7L, "普通消息");
+ PrivateMessage nullContent = message(103L, -100L, null);
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(kick, normal, nullContent));
+
+ List result = service.loadOfflineMessage(0L);
+
+ assertEquals(List.of(102L, 103L), result.stream().map(PrivateMessageVO::getId).toList());
+ verify(moderation, never()).toMobileVO(kick);
+ ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectList(query.capture());
+ assertLegacyKickSqlFilter((AbstractWrapper) query.getValue());
+ }
+
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ void historyFiltersBeforePaginationAndRechecksRefreshedRows() {
+ PrivateMessage normal = message(102L, -100L, "普通消息");
+ PrivateMessage kick = legacyKick();
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(normal));
+ // A fresh-state read must not reintroduce a control record excluded by the initial query.
+ when(moderation.refreshVisibleMessages(anyList())).thenReturn(List.of(normal, kick));
+
+ List result = service.findHistoryMessage(-100L, 1L, 10L);
+
+ assertEquals(List.of(102L), result.stream().map(PrivateMessageVO::getId).toList());
+ verify(moderation, never()).toMobileVO(kick);
+ ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectList(query.capture());
+ AbstractWrapper wrapper = (AbstractWrapper) query.getValue();
+ assertLegacyKickSqlFilter(wrapper);
+ String sql = wrapper.getSqlSegment();
+ assertTrue(sql.contains("limit 0,10"), sql);
+ assertTrue(sql.indexOf("NOT (") < sql.indexOf("limit 0,10"), sql);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void offlineWebSocketDoesNotPushHistoricalKickButCompletesLoading() throws Exception {
+ PrivateMessage kick = legacyKick();
+ PrivateMessage normal = message(102L, 7L, "普通消息");
+ when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(normal));
+ when(moderation.refreshVisibleMessages(anyList())).thenReturn(List.of(kick, normal));
+ when(imClient.isOnline(eq(42L), any(IMTerminalType.class))).thenReturn(true);
+ List pushed = Collections.synchronizedList(new ArrayList<>());
+ CountDownLatch finished = new CountDownLatch(1);
+ doAnswer(call -> {
+ IMPrivateMessage envelope = call.getArgument(0);
+ PrivateMessageVO data = envelope.getData();
+ pushed.add(data);
+ if (Integer.valueOf(30).equals(data.getType()) && "false".equals(data.getContent())) {
+ finished.countDown();
+ }
+ return null;
+ }).when(imClient).sendPrivateMessage(any(IMPrivateMessage.class));
+
+ service.pullOfflineMessage(0L);
+
+ assertTrue(finished.await(5, TimeUnit.SECONDS), "Offline push must finish loading");
+ assertEquals(3, pushed.size());
+ assertEquals(List.of(102L), pushed.stream().map(PrivateMessageVO::getId)
+ .filter(java.util.Objects::nonNull).toList());
+ assertTrue(pushed.stream().noneMatch(m -> PrivateMessageSessionControl.hasKickPrefix(m.getContent())));
+ verify(moderation, never()).toMobileVO(kick);
+ }
+
+ private void assertLegacyKickSqlFilter(AbstractWrapper, ?, ?> wrapper) {
+ String sql = wrapper.getSqlSegment();
+ assertTrue(sql.contains("NOT (COALESCE(send_id"), sql);
+ assertTrue(sql.contains("BINARY LEFT(COALESCE(content"), sql);
+ assertFalse(sql.contains("LIKE"), sql);
+ assertTrue(wrapper.getParamNameValuePairs().containsValue(PrivateMessageSessionControl.KICK_PREFIX));
+ assertTrue(wrapper.getParamNameValuePairs().containsValue(-100L));
+ }
+
+ private PrivateMessage legacyKick() {
+ return message(101L, -100L, PrivateMessageSessionControl.KICK_PREFIX + "账号已被禁用");
+ }
+
+ private PrivateMessage message(Long id, Long senderId, String content) {
+ PrivateMessage message = new PrivateMessage();
+ message.setId(id);
+ message.setSendId(senderId);
+ message.setRecvId(42L);
+ message.setType(0);
+ message.setStatus(3);
+ message.setContent(content);
+ return message;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateConversationServiceTest.java b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateConversationServiceTest.java
new file mode 100644
index 0000000..03ff47b
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateConversationServiceTest.java
@@ -0,0 +1,129 @@
+package com.bx.implatform.service.third.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bx.imclient.IMClient;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateConversationDTO;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.mapper.UserMapper;
+import com.bx.implatform.service.FriendService;
+import com.bx.implatform.util.SensitiveFilterUtil;
+import com.bx.implatform.vo.third.ThirdTableVO;
+import com.bx.implatform.vo.third.privatemsg.ThirdPrivateConversationVO;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ThirdPrivateConversationServiceTest {
+ @Mock
+ private PrivateMessageMapper mapper;
+ @Mock
+ private UserMapper userMapper;
+ private ThirdPrivateMsgServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ service = new ThirdPrivateMsgServiceImpl(userMapper, mapper, mock(IMClient.class),
+ mock(FriendService.class), mock(SensitiveFilterUtil.class));
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), PrivateMessage.class);
+ }
+
+ @Test
+ void noMatchingConversationsReturnsEmptyRowsAndSkipsPageQuery() {
+ ThirdTableVO page = service.conversationPage(new ThirdPrivateConversationDTO());
+ assertEquals(0, page.getTotal());
+ assertNotNull(page.getRows());
+ assertTrue(page.getRows().isEmpty());
+ verify(mapper, never()).selectConversations(any(), anyLong());
+ }
+
+ @Test
+ void pageBeyondEndRetainsTotalAndReturnsEmptyRows() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setPageNum(3);
+ dto.setPageSize(10);
+ when(mapper.countConversations(any())).thenReturn(12L);
+ ThirdTableVO page = service.conversationPage(dto);
+ assertEquals(12, page.getTotal());
+ assertTrue(page.getRows().isEmpty());
+ verify(mapper, never()).selectConversations(any(), anyLong());
+ }
+
+ @Test
+ void trimsParticipantFiltersAndUsesRequestedOffset() {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setPageNum(2);
+ dto.setPageSize(10);
+ dto.setUserKeyword(" Alice ");
+ dto.setPeerKeyword(" 2 ");
+ when(mapper.countConversations(any())).thenReturn(12L);
+ when(mapper.selectConversations(any(), eq(10L))).thenReturn(Collections.emptyList());
+ service.conversationPage(dto);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPrivateConversationDTO.class);
+ verify(mapper).selectConversations(captor.capture(), eq(10L));
+ assertEquals("Alice", captor.getValue().getUserKeyword());
+ assertEquals("2", captor.getValue().getPeerKeyword());
+ }
+
+ @Test
+ void rejectsMalformedOrReversedConversationDatesBeforeReadingDatabase() {
+ for (List dates : List.of(List.of(new Date(10)),
+ List.of(new Date(20), new Date(10)), Arrays.asList(new Date(10), null))) {
+ ThirdPrivateConversationDTO dto = new ThirdPrivateConversationDTO();
+ dto.setLastSendTimeList(dates);
+ assertThrows(GlobalException.class, () -> service.conversationPage(dto));
+ }
+ verifyNoInteractions(mapper);
+ }
+
+ @Test
+ void malformedDetailDateRangeDoesNotReachMapper() {
+ ThirdPrivateMessageDTO dto = new ThirdPrivateMessageDTO();
+ dto.setPageNum(1);
+ dto.setPageSize(10);
+ dto.setSendTimeList(List.of(new Date(10)));
+ assertThrows(GlobalException.class, () -> service.page(dto));
+ verifyNoInteractions(mapper);
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void bilateralDetailUsesTimeThenIdOrderingAndAlwaysReturnsArray() {
+ ThirdPrivateMessageDTO dto = new ThirdPrivateMessageDTO();
+ dto.setPageNum(1);
+ dto.setPageSize(10);
+ dto.setSendId(1L);
+ dto.setRecvId(2L);
+ dto.setSendTimeList(Collections.emptyList());
+ when(mapper.selectPage(any(Page.class), any())).thenReturn(new Page(1, 10));
+ ThirdTableVO> page = service.page(dto);
+ assertNotNull(page.getRows());
+ assertTrue(page.getRows().isEmpty());
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(mapper).selectPage(any(Page.class), captor.capture());
+ String sql = captor.getValue().getSqlSegment().toLowerCase();
+ assertTrue(sql.contains("order by send_time desc,id desc"), sql);
+ assertTrue(sql.contains(" or "), sql);
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateSessionControlServiceTest.java b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateSessionControlServiceTest.java
new file mode 100644
index 0000000..8620d2e
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdPrivateSessionControlServiceTest.java
@@ -0,0 +1,150 @@
+package com.bx.implatform.service.third.impl;
+
+import com.bx.imclient.IMClient;
+import com.bx.imcommon.model.IMPrivateMessage;
+import com.bx.implatform.contant.Constant;
+import com.bx.implatform.dto.third.privatemsg.ThirdPrivateMessageSendDTO;
+import com.bx.implatform.entity.PrivateMessage;
+import com.bx.implatform.enums.MessageStatus;
+import com.bx.implatform.enums.MessageType;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.PrivateMessageMapper;
+import com.bx.implatform.mapper.UserMapper;
+import com.bx.implatform.service.FriendService;
+import com.bx.implatform.util.PrivateMessageSessionControl;
+import com.bx.implatform.util.SensitiveFilterUtil;
+import com.bx.implatform.vo.PrivateMessageVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class ThirdPrivateSessionControlServiceTest {
+ private PrivateMessageMapper mapper;
+ private UserMapper userMapper;
+ private IMClient imClient;
+ private FriendService friends;
+ private SensitiveFilterUtil sensitiveFilter;
+ private ThirdPrivateMsgServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(PrivateMessageMapper.class);
+ userMapper = mock(UserMapper.class);
+ imClient = mock(IMClient.class);
+ friends = mock(FriendService.class);
+ sensitiveFilter = mock(SensitiveFilterUtil.class);
+ service = new ThirdPrivateMsgServiceImpl(userMapper, mapper, imClient, friends, sensitiveFilter);
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void kickUsesExistingPrivateEnvelopeWithoutPersistingOrRequestingDeliveryCallback() {
+ ThirdPrivateMessageSendDTO dto = kick();
+ PrivateMessageVO result = service.sendMessage(dto);
+
+ assertNotNull(result);
+ assertEquals(0L, result.getId());
+ assertEquals(dto.getSendId(), result.getSendId());
+ assertEquals(dto.getRecvId(), result.getRecvId());
+ assertEquals(dto.getContent(), result.getContent());
+ assertEquals(dto.getTmpId(), result.getTmpId());
+ assertEquals(MessageType.TEXT.code(), result.getType());
+ assertEquals(MessageStatus.READED.code(), result.getStatus());
+ assertNotNull(result.getSendTime());
+ ArgumentCaptor push = ArgumentCaptor.forClass(IMPrivateMessage.class);
+ verify(imClient).sendPrivateMessage(push.capture());
+ assertSame(result, push.getValue().getData());
+ assertEquals(dto.getSendId(), push.getValue().getSender().getId());
+ assertEquals(dto.getTerminal(), push.getValue().getSender().getTerminal());
+ assertEquals(dto.getRecvId(), push.getValue().getRecvId());
+ assertFalse(push.getValue().getSendToSelf());
+ assertFalse(push.getValue().getSendResult());
+ verifyNoInteractions(mapper, userMapper, friends, sensitiveFilter);
+ }
+
+ @Test
+ void forgedSenderOrNonTextKickContentIsRejectedBeforeAnySideEffect() {
+ ThirdPrivateMessageSendDTO ordinarySender = kick();
+ ordinarySender.setSendId(7L);
+ assertThrows(GlobalException.class, () -> service.sendMessage(ordinarySender));
+ ThirdPrivateMessageSendDTO nonText = kick();
+ nonText.setType(MessageType.IMAGE.code());
+ assertThrows(GlobalException.class, () -> service.sendMessage(nonText));
+ ThirdPrivateMessageSendDTO noType = kick();
+ noType.setType(null);
+ assertThrows(GlobalException.class, () -> service.sendMessage(noType));
+ verifyNoInteractions(mapper, userMapper, friends, sensitiveFilter, imClient);
+ }
+
+ @Test
+ void failedRealtimePushDoesNotLeaveAnOfflineKickToReplay() {
+ doThrow(new IllegalStateException("temporary transport failure")).when(imClient).sendPrivateMessage(any());
+ assertThrows(IllegalStateException.class, () -> service.sendMessage(kick()));
+ verifyNoInteractions(mapper, userMapper, friends, sensitiveFilter);
+ }
+
+ @Test
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ void ordinaryTextKeepsPersistedDeliveryAndFriendshipChecks() {
+ ThirdPrivateMessageSendDTO dto = kick();
+ dto.setSendId(7L);
+ dto.setContent("ordinary chat text");
+ when(friends.isFriend(7L, dto.getRecvId())).thenReturn(true);
+ when(sensitiveFilter.filter(dto.getContent())).thenReturn("filtered chat text");
+ when(mapper.insert(any(PrivateMessage.class))).thenAnswer(invocation -> {
+ invocation.getArgument(0).setId(101L);
+ return 1;
+ });
+
+ PrivateMessageVO result = service.sendMessage(dto);
+
+ assertEquals(101L, result.getId());
+ assertEquals("filtered chat text", result.getContent());
+ assertEquals(MessageStatus.PENDING.code(), result.getStatus());
+ verify(friends).isFriend(7L, dto.getRecvId());
+ verify(mapper).insert(any(PrivateMessage.class));
+ ArgumentCaptor push = ArgumentCaptor.forClass(IMPrivateMessage.class);
+ verify(imClient).sendPrivateMessage(push.capture());
+ assertTrue(push.getValue().getSendToSelf());
+ assertTrue(push.getValue().getSendResult());
+ }
+
+ @Test
+ void recognitionUsesExactPrefixAndTrustedSenderAndType() {
+ PrivateMessage legacy = new PrivateMessage();
+ legacy.setSendId(Constant.COM_MESSAGE_USER_ID);
+ legacy.setType(MessageType.TEXT.code());
+ legacy.setContent(PrivateMessageSessionControl.KICK_PREFIX + "账号已被禁用");
+ assertTrue(PrivateMessageSessionControl.isKickMessage(legacy));
+ legacy.setSendId(7L);
+ assertFalse(PrivateMessageSessionControl.isKickMessage(legacy));
+ legacy.setSendId(Constant.COM_MESSAGE_USER_ID);
+ legacy.setType(null);
+ assertFalse(PrivateMessageSessionControl.isKickMessage(legacy));
+ legacy.setType(MessageType.TEXT.code());
+ legacy.setContent(null);
+ assertFalse(PrivateMessageSessionControl.isKickMessage(legacy));
+ assertFalse(PrivateMessageSessionControl.isKickMessage(null));
+ assertFalse(PrivateMessageSessionControl.hasKickPrefix("__app_kick_session__:text"));
+ assertFalse(PrivateMessageSessionControl.hasKickPrefix("xxAPPxKICKxSESSIONxx:text"));
+ assertFalse(PrivateMessageSessionControl.hasKickPrefix("prefix " + PrivateMessageSessionControl.KICK_PREFIX));
+ assertFalse(PrivateMessageSessionControl.hasKickPrefix(null));
+ }
+
+ private ThirdPrivateMessageSendDTO kick() {
+ ThirdPrivateMessageSendDTO dto = new ThirdPrivateMessageSendDTO();
+ dto.setSendId(Constant.COM_MESSAGE_USER_ID);
+ dto.setRecvId(42L);
+ dto.setTerminal(1);
+ dto.setTmpId("kick-42");
+ dto.setType(MessageType.TEXT.code());
+ dto.setContent(PrivateMessageSessionControl.KICK_PREFIX + "账号已被禁用");
+ return dto;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdUserServiceImplTest.java b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdUserServiceImplTest.java
new file mode 100644
index 0000000..9617f4f
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/third/impl/ThirdUserServiceImplTest.java
@@ -0,0 +1,217 @@
+package com.bx.implatform.service.third.impl;
+
+import com.bx.imclient.IMClient;
+import com.bx.implatform.config.props.JwtProperties;
+import com.bx.implatform.dto.third.user.ThirdCreateOrUpdateUserDTO;
+import com.bx.implatform.entity.User;
+import com.bx.implatform.enums.ResultCode;
+import com.bx.implatform.exception.GlobalException;
+import com.bx.implatform.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataAccessResourceFailureException;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ThirdUserServiceImplTest {
+
+ @Mock
+ private UserMapper mapper;
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ private ThirdUserServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ service = new ThirdUserServiceImpl(passwordEncoder, new JwtProperties(), mock(IMClient.class));
+ ReflectionTestUtils.setField(service, "baseMapper", mapper);
+ }
+
+ @Test
+ void createsUserWithRequestedIdAndInitialPassword() {
+ ThirdCreateOrUpdateUserDTO dto = request();
+ when(passwordEncoder.encode(dto.generatePassword())).thenReturn("initial-password-hash");
+
+ service.createOrUpdate(dto);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(User.class);
+ verify(mapper).insert(captor.capture());
+ User inserted = captor.getValue();
+ assertEquals(-100L, inserted.getId());
+ assertEquals(dto.getUserName(), inserted.getUserName());
+ assertEquals(dto.getNickName(), inserted.getNickName());
+ assertEquals(dto.getAvatar(), inserted.getHeadImage());
+ assertEquals(dto.getAvatar(), inserted.getHeadImageThumb());
+ assertEquals("initial-password-hash", inserted.getPassword());
+ }
+
+ @Test
+ void missingNicknameDefaultsToUsernameOnCreation() {
+ ThirdCreateOrUpdateUserDTO dto = request();
+ dto.setNickName(null);
+ when(passwordEncoder.encode(dto.generatePassword())).thenReturn("initial-password-hash");
+
+ service.createOrUpdate(dto);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(User.class);
+ verify(mapper).insert(captor.capture());
+ assertEquals(dto.getUserName(), captor.getValue().getNickName());
+ }
+
+ @Test
+ void repeatedInitializationSkipsWritesAndPasswordEncoding() {
+ when(mapper.selectById(-100L)).thenReturn(existingUser());
+
+ service.createOrUpdate(request());
+
+ verify(mapper, never()).insert(any(User.class));
+ verify(mapper, never()).updateById(any(User.class));
+ verifyNoInteractions(passwordEncoder);
+ }
+
+ @Test
+ void updatesProfileWithoutOverwritingPasswordOrBanState() {
+ User existing = existingUser();
+ existing.setPassword("existing-password");
+ existing.setIsBanned(true);
+ existing.setReason("existing-ban-reason");
+ when(mapper.selectById(-100L)).thenReturn(existing);
+ ThirdCreateOrUpdateUserDTO dto = request();
+ dto.setNickName("new nickname");
+
+ service.createOrUpdate(dto);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(User.class);
+ verify(mapper).updateById(captor.capture());
+ User update = captor.getValue();
+ assertEquals("new nickname", update.getNickName());
+ assertNull(update.getPassword());
+ assertNull(update.getIsBanned());
+ assertNull(update.getReason());
+ assertEquals("existing-password", existing.getPassword());
+ assertTrue(existing.getIsBanned());
+ verifyNoInteractions(passwordEncoder);
+ }
+
+ @Test
+ void omittedOptionalFieldsDoNotTriggerAnUpdate() {
+ when(mapper.selectById(-100L)).thenReturn(existingUser());
+ ThirdCreateOrUpdateUserDTO dto = request();
+ dto.setNickName(null);
+ dto.setAvatar(null);
+
+ service.createOrUpdate(dto);
+
+ verify(mapper, never()).updateById(any(User.class));
+ verifyNoInteractions(passwordEncoder);
+ }
+
+ @Test
+ void outdatedThumbnailIsUpdatedEvenWhenOriginalAvatarMatches() {
+ User existing = existingUser();
+ existing.setHeadImageThumb("old-thumbnail");
+ when(mapper.selectById(-100L)).thenReturn(existing);
+
+ service.createOrUpdate(request());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(User.class);
+ verify(mapper).updateById(captor.capture());
+ assertEquals(request().getAvatar(), captor.getValue().getHeadImageThumb());
+ }
+
+ @Test
+ void concurrentCreationOfSameUserAndProfileSucceedsWithoutUpdatingPassword() {
+ when(mapper.selectById(-100L)).thenReturn(null, existingUser());
+ when(passwordEncoder.encode(any())).thenReturn("unused-password-hash");
+ when(mapper.insert(any(User.class))).thenThrow(new DuplicateKeyException("PRIMARY"));
+
+ assertDoesNotThrow(() -> service.createOrUpdate(request()));
+
+ verify(mapper, never()).updateById(any(User.class));
+ }
+
+ @Test
+ void concurrentCreationUpdatesRequestedProfileButKeepsExistingCredentials() {
+ User concurrentUser = existingUser();
+ concurrentUser.setNickName("old nickname");
+ when(mapper.selectById(-100L)).thenReturn(null, concurrentUser);
+ when(passwordEncoder.encode(any())).thenReturn("unused-password-hash");
+ when(mapper.insert(any(User.class))).thenThrow(new DuplicateKeyException("PRIMARY"));
+
+ service.createOrUpdate(request());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(User.class);
+ verify(mapper).updateById(captor.capture());
+ assertEquals(request().getNickName(), captor.getValue().getNickName());
+ assertNull(captor.getValue().getPassword());
+ }
+
+ @Test
+ void usernameConflictOnCreationReturnsBusinessErrorWithoutUpdatingAnotherUser() {
+ when(passwordEncoder.encode(any())).thenReturn("initial-password-hash");
+ when(mapper.insert(any(User.class))).thenThrow(new DuplicateKeyException("idx_user_name"));
+
+ GlobalException exception = assertThrows(GlobalException.class,
+ () -> service.createOrUpdate(request()));
+
+ assertEquals(ResultCode.USERNAME_ALREADY_REGISTER.getCode(), exception.getCode());
+ verify(mapper, never()).updateById(any(User.class));
+ }
+
+ @Test
+ void usernameConflictOnUpdateReturnsBusinessError() {
+ User existing = existingUser();
+ existing.setUserName("old-username");
+ when(mapper.selectById(-100L)).thenReturn(existing);
+ when(mapper.updateById(any(User.class))).thenThrow(new DuplicateKeyException("idx_user_name"));
+
+ GlobalException exception = assertThrows(GlobalException.class,
+ () -> service.createOrUpdate(request()));
+
+ assertEquals(ResultCode.USERNAME_ALREADY_REGISTER.getCode(), exception.getCode());
+ }
+
+ @Test
+ void databaseFailureIsNotReportedAsSuccessfulSynchronization() {
+ User existing = existingUser();
+ existing.setNickName("old nickname");
+ when(mapper.selectById(-100L)).thenReturn(existing);
+ DataAccessResourceFailureException failure = new DataAccessResourceFailureException("database unavailable");
+ when(mapper.updateById(any(User.class))).thenThrow(failure);
+
+ assertSame(failure, assertThrows(DataAccessResourceFailureException.class,
+ () -> service.createOrUpdate(request())));
+ }
+
+ private ThirdCreateOrUpdateUserDTO request() {
+ ThirdCreateOrUpdateUserDTO dto = new ThirdCreateOrUpdateUserDTO();
+ dto.setUserId(-100L);
+ dto.setUserName("message-notification");
+ dto.setPhone("15555555555");
+ dto.setNickName("消息通知");
+ dto.setAvatar("https://example.com/avatar.png");
+ return dto;
+ }
+
+ private User existingUser() {
+ ThirdCreateOrUpdateUserDTO dto = request();
+ User user = new User();
+ user.setId(dto.getUserId());
+ user.setUserName(dto.getUserName());
+ user.setNickName(dto.getNickName());
+ user.setHeadImage(dto.getAvatar());
+ user.setHeadImageThumb(dto.getAvatar());
+ return user;
+ }
+}
diff --git a/scripts/test-moderation-client.cjs b/scripts/test-moderation-client.cjs
new file mode 100644
index 0000000..2ba1d45
--- /dev/null
+++ b/scripts/test-moderation-client.cjs
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+'use strict'
+
+// Exercises the installed mobile client's real store code without modifying or starting the app.
+// Run: node --test scripts/test-moderation-client.cjs [CLIENT_REPO may be provided in the environment].
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const vm = require('node:vm')
+const { test } = require('node:test')
+
+const clientRoot = process.env.CLIENT_REPO || path.resolve(__dirname, '../../../Friendship-and-chat-uniapp')
+const storePath = path.join(clientRoot, 'src/chat-store/chatStore.js')
+const enumsPath = path.join(clientRoot, 'src/common/enums.js')
+const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value))
+const chatInfo = { type: 'PRIVATE', targetId: 7 }
+
+function harness() {
+ const memory = new Map()
+ const context = vm.createContext({
+ defineStore: (_name, definition) => definition,
+ useUserStore: () => ({ userInfo: { id: 42 } }),
+ useFriendStore: () => ({ findFriend: () => null }),
+ useGroupStore: () => ({ findGroup: () => null }),
+ console: { log() {} },
+ uni: {
+ setStorageSync: (key, value) => memory.set(key, clone(value)),
+ getStorageSync: (key) => clone(memory.get(key)),
+ removeStorageSync: (key) => memory.delete(key)
+ }
+ })
+ const enumSource = fs.readFileSync(enumsPath, 'utf8').replace(/export\s*\{[\s\S]*?\}\s*;?\s*$/, '')
+ vm.runInContext(enumSource, context, { filename: enumsPath })
+ const storeSource = fs.readFileSync(storePath, 'utf8')
+ .replace(/^import\s+.*?;?\s*$/gm, '')
+ .replace('export default defineStore', 'globalThis.definition = defineStore')
+ vm.runInContext(storeSource, context, { filename: storePath })
+ const definition = context.definition
+ const store = definition.state()
+ for (const [key, action] of Object.entries(definition.actions)) store[key] = action.bind(store)
+ for (const [key, getter] of Object.entries(definition.getters)) {
+ Object.defineProperty(store, key, { get: () => getter(store) })
+ }
+ return { store, memory }
+}
+
+function message(id, content = 'original private content') {
+ return { id, type: 0, status: 3, content, sendId: 7, recvId: 42, sendTime: 1000 + id, selfSend: false }
+}
+
+function quote(id) {
+ return { ...message(id, 'reply text'), quoteMessage: message(101) }
+}
+
+function conversation(messages, hotMinIdx = 0) {
+ return {
+ ...chatInfo, showName: 'Sender', messages, hotMinIdx, unreadCount: 2,
+ readedMessageIdx: 0, lastContent: 'original private content', lastSendTime: 1101,
+ stored: false, hidden: false
+ }
+}
+
+function recall(selfSend = false) {
+ // The persisted event has its own ID; its legacy-client wire projection must use the source ID.
+ return { id: 101, content: '101', type: 10, status: 3, sendId: 7, recvId: 42, sendTime: 2000, selfSend }
+}
+
+function assertHidden(value, expectedText = '对方撤回了一条消息') {
+ assert.equal(value.type, 21)
+ assert.equal(value.status, 2)
+ assert.equal(value.content, expectedText)
+}
+
+for (const { name, split, messages } of [
+ { name: 'hot source and hot quote', split: 0, messages: [message(101), quote(102)] },
+ { name: 'cold source and hot quote', split: 1, messages: [message(101), quote(102)] },
+ { name: 'cold source and cold quote', split: 2, messages: [message(101), quote(102), message(103, 'later message')] }
+]) {
+ test(`legacy RECALL hides ${name} and persists both affected partitions`, () => {
+ const { store, memory } = harness()
+ const chat = conversation(clone(messages), split)
+ store.chats = [chat]
+ store.saveToStorage(true)
+ store.recallMessage(recall(), chatInfo)
+ assertHidden(chat.messages[0])
+ assertHidden(chat.messages[1].quoteMessage, '引用内容已撤回')
+ const cold = memory.get('chats-app-42-PRIVATE-7')?.messages || []
+ const hot = memory.get('chats-app-42-PRIVATE-7-hot')?.messages || []
+ const persisted = [...cold, ...hot]
+ assertHidden(persisted.find((item) => item.id === 101))
+ assertHidden(persisted.find((item) => item.id === 102).quoteMessage, '引用内容已撤回')
+ assert.equal(chat.unreadCount, 2)
+ assert.equal(chat.lastContent, '对方撤回了一条消息')
+ })
+}
+
+test('repeat notifications with status 3 do not add unread counts or duplicate rows', () => {
+ const { store, memory } = harness()
+ const chat = conversation([message(101), quote(102)], 1)
+ store.chats = [chat]
+ store.saveToStorage(true)
+ store.recallMessage(recall(), chatInfo)
+ store.recallMessage(recall(), chatInfo)
+ assert.equal(chat.unreadCount, 2)
+ assert.equal(chat.messages.length, 2)
+ assert.equal(memory.get('chats-app-42-PRIVATE-7-hot').unreadCount, 2)
+ assertHidden(chat.messages[0])
+})
+
+test('sender receives the same compatible projection and sees their own recall tip', () => {
+ const { store } = harness()
+ const chat = conversation([message(101)])
+ store.chats = [chat]
+ store.recallMessage(recall(true), chatInfo)
+ assertHidden(chat.messages[0], '你撤回了一条消息')
+ assert.equal(chat.unreadCount, 2)
+})
+
+test('offline buffer processing persists hidden source and quotes after refresh', () => {
+ const { store, memory } = harness()
+ store.initChats({ chats: [conversation([message(101), quote(102)], 2)], privateMsgMaxId: 102 })
+ store.setLoading(true)
+ store.recallMessage(recall(), chatInfo)
+ assert.equal(memory.size, 0)
+ store.setLoading(false)
+ store.refreshChats()
+ assertHidden(store.chats[0].messages[0])
+ assertHidden(store.chats[0].messages[1].quoteMessage, '引用内容已撤回')
+ const cold = memory.get('chats-app-42-PRIVATE-7').messages
+ assertHidden(cold[0])
+ assertHidden(cold[1].quoteMessage, '引用内容已撤回')
+ assert.equal(store.chats[0].unreadCount, 2)
+})
+
+test('unknown conversation is ignored without creating empty chats or unread counts', () => {
+ const { store, memory } = harness()
+ assert.doesNotThrow(() => store.recallMessage(recall(), chatInfo))
+ assert.equal(store.chats.length, 0)
+ assert.equal(memory.size, 0)
+})