fix(chat): Phase 09 复验 —— 修复 WebSocket 下行死链路 / Unmatch 后可发消息 / Block 未落库 / 聊天 IDOR

对 Phase 09(Text Chat)执行 KEEP-VERIFY/EXTEND 复验:不采信历史 ✅,逐条核对
REST 发送 + WebSocket 接收、幂等、重连、未读计数、非 active Match 禁聊、Unmatch/
Block 即时禁聊、活动双确认会话复用。共发现并修复 4 处真实缺陷 + 1 处规范不符。

D1(Critical)WebSocket 下行整条链路是死代码
  - 无 HandshakeInterceptor,无任一环节把 userId 写入 session attributes,
    extractUserId 恒返回 null;sendToUser/sendToConversation 全仓库零调用点。
  - 新增 ChatHandshakeInterceptor:从 ?token= 或 Authorization: Bearer 解析 JWT,
    校验通过后写 session attr userId;失败 fail-closed 拒绝握手;仅接受 type=access。
  - 重写 ChatWebSocketHandler:按规范 {event,data} 信封推送;未认证连接直接
    close(NOT_ACCEPTABLE);同账号重连由新连接接管、旧连接 close(NORMAL);
    上行仅支持 ping->pong,禁止客户端自订阅。
  - ChatService.sendText 落库+递增未读后推送 message.new;RiskNotice 同推。
  - 移除 WebSocketConfig 的 .withSockJS()(SockJS 改写端点致原生 WS 无法直连)。

D2(Critical)Unmatch 后仍可发消息
  - unmatch 只改 Match,不联动会话;sendText 仅校验 conv.status==active。
  - 新增 ChatService.assertMatchAlive(conv):按 matchId 查 Match,状态非
    {active,getting_to_know} 抛 "匹配已解除,无法发送消息"。sendText 在 conv.status
    校验之后调用。
  - MatchController.unmatch 落库后调 chatService.syncFromMatch 推 conversation.closed。
  - syncFromMatch 重写:alive 恢复 active;非 alive 只推 conversation.closed,
    不改 status(会话仍以灰显态留在列表,属未授权产品决策,登记待决)。

D3(Critical)RelationService.block 调静态 MatchService.unmatch 只改内存态不落库
  - 结果:Block 后 matches.status 仍为 active。
  - 补 matchMapper.updateById(m) 持久化 + chatService.closeConversationForMatch(
    CONVERSATION_STATUS_BLOCKED) 通知对端。RelationshipService.block 同步补会话关闭通知。

D4(Critical 安全)聊天 4 个接口无成员校验 -> IDOR 越权读私聊
  - MessageController.load/markRead、ConversationController.detail/unread 补
    chatService.isMember(conversationId, userId) 校验;非成员返回
    ApiResponse.notFound()(body 级 success=false code=NOT_FOUND,HTTP 200,
    与 MatchController.authorizedMatch 同约定),不区分"不存在"与"无权"以防探测。

D5(Medium)上行订阅/信封不符规范
  - REST API 接口详细设计规范.md 第124-126章要求禁止客户端自订阅,事件名
    message.new/message.read/conversation.closed,信封 {event,data}。已对齐。

测试保真度 + 测试补齐
  - schema-h2.sql 新增 uk_message_client_id(sender_id, client_message_id),
    对齐生产 V004:71;H2 不支持过滤索引故省略 WHERE(NULL 语义等价)。
    否则 service 的 SELECT-then-INSERT 竞态在测试中无 DB 兜底,形成测试盲区。
  - 新增 ChatPhase09IntegrationTest(13 用例):幂等重试不重复投递、推送信封字段、
    未读增减清零、Match unmatched 拒发、Unmatch/Block 端点即时禁聊+收 closed 事件、
    活动双确认复用同一消息体系、未形成 Match 无法建会话、非成员读消息/详情/未读
    被拒且不泄露正文、DB 唯一索引兜底重复 clientMessageId、无幂等键系统消息可共存。
  - 新增 ChatWebSocketHandlerTest(7 用例):userId 路由、信封格式、未认证关连、
    断开不推、重连替换旧连接、上行仅 ping。

验证证据
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 308,+20)。
  - 负向验证:临时注释 assertMatchAlive + 两处 isMember,恰好 3 个用例变红,
    证明守卫有效;随后还原并 grep 确认无残留。

文档
  - 新增 Phase-09-V11-Recovery-Completion-Report-2026-09-16.md(完整报告)。
  - Phase-09-V11-Completion-Report.md 顶部加状态注记,推翻其 4 条 WebSocket 结论。
  - 计划/STATUS/JIRA_BACKLOG 同步 Phase 09 完成态与测试总数(328)。

Phase 10 未启动,按计划每阶段出报告后停止等待确认。
This commit is contained in:
WorkBuddy
2026-09-16 16:42:48 +08:00
parent 4e478ce6ba
commit 03a03c7c11
17 changed files with 1295 additions and 63 deletions
@@ -0,0 +1,451 @@
package com.danyang.match.platform.chat;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.auth.token.TokenBlacklistService;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.entity.Message;
import com.danyang.match.platform.chat.mapper.ConversationMapper;
import com.danyang.match.platform.chat.mapper.ConversationMemberMapper;
import com.danyang.match.platform.chat.mapper.MessageMapper;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.chat.websocket.ChatWebSocketHandler;
import com.danyang.match.platform.match.controller.MatchController;
import com.danyang.match.platform.match.entity.Match;
import com.danyang.match.platform.match.entity.UserRelation;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Phase 09:Text Chat 集成验证(真实 H2 + 真实 MyBatis SQL + 真实 HTTP 层)。
*
* <p>验证计划要求的三条:
* <ol>
* <li>REST send + WebSocket receive、消息幂等、未读计数;</li>
* <li>非 active Match 禁聊,Unmatch / Block 后即时禁聊;</li>
* <li>活动双确认产生的 Conversation 复用同一消息体系。</li>
* </ol>
*
* <p>Gate:未 Mutual 或活动未双确认时不能发消息;正常会话不重复投递。
*
* <p>本轮复验发现并锁定的历史缺陷:
* <ul>
* <li>{@code MatchController.unmatch} 只改 Match 状态、从不联动会话 → Unmatch 后仍可发消息;</li>
* <li>{@code RelationService.block} 调用的是静态工具方法 {@code MatchService.unmatch},
* 该方法只改内存态不落库 → Block 后 Match 仍为 active;</li>
* <li>{@code ChatService.sendText} 之前只校验 conversation.status,不校验 Match 活性;</li>
* <li>{@code ChatService.sendText} 落库后从不推送 → WebSocket 下行链路整体是死代码;</li>
* <li>{@code MessageController.load/markRead}、{@code ConversationController.detail/unread}
* 无会话成员校验 → 任意登录用户可读他人私聊(IDOR)。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
class ChatPhase09IntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private ChatService chatService;
@Autowired private RelationService relationService;
@Autowired private RelationshipService relationshipService;
@Autowired private MatchController matchController;
@Autowired private MatchMapper matchMapper;
@Autowired private UserRelationMapper relationMapper;
@Autowired private UserMapper userMapper;
@Autowired private MessageMapper messageMapper;
@Autowired private ConversationMapper conversationMapper;
@Autowired private ConversationMemberMapper memberMapper;
@Autowired private JwtTokenService jwtTokenService;
/** 测试环境无 Redis 服务:黑名单查询打桩为"未拉黑",否则 validateToken 会抛连接异常 */
@MockBean private TokenBlacklistService tokenBlacklistService;
/** 限流用 Redis 打桩,避免依赖外部服务;断言点不在这里 */
@MockBean private StringRedisTemplate redisTemplate;
/** 下行推送打桩,用于断言"发消息后确实推送了、且只推一次" */
@MockBean private ChatWebSocketHandler webSocketHandler;
private String userA;
private String userB;
private String outsider;
private Match match;
private Conversation conv;
@BeforeEach
void setUp() {
ValueOperations<String, String> valueOps = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
userA = createUser("chat_a");
userB = createUser("chat_b");
outsider = createUser("chat_outsider");
match = createMatch(userA, userB, Constants.MATCH_SOURCE_USER_LIKE, Constants.MATCH_STATUS_ACTIVE);
conv = chatService.getOrCreateConversation(match.getId(), userA, userB);
}
@AfterEach
void tearDown() {
cleanupConversation(conv.getId());
matchMapper.selectList(new LambdaQueryWrapper<Match>()
.in(Match::getUserAId, List.of(userA, userB, outsider))
.or().in(Match::getUserBId, List.of(userA, userB, outsider)))
.forEach(m -> matchMapper.deleteById(m.getId()));
relationMapper.selectList(new LambdaQueryWrapper<UserRelation>()
.in(UserRelation::getUserId, List.of(userA, userB, outsider))
.or().in(UserRelation::getTargetUserId, List.of(userA, userB, outsider)))
.forEach(r -> relationMapper.deleteById(r.getId()));
userMapper.deleteById(userA);
userMapper.deleteById(userB);
userMapper.deleteById(outsider);
}
// ==================== 1. REST send + 幂等 + 未读 ====================
@Test
@DisplayName("Gate: 正常会话不重复投递 —— 相同 clientMessageId 重试不落两条、未读只 +1、只推一次")
void idempotentRetryDoesNotDuplicateNorDoublePush() {
Message first = chatService.sendText(conv.getId(), userA, "你好", "cmid-1");
Message replay = chatService.sendText(conv.getId(), userA, "你好", "cmid-1");
assertNotNull(first.getId());
assertEquals(first.getId(), replay.getId(), "幂等重放应返回同一条消息");
Long rows = messageMapper.selectCount(
new LambdaQueryWrapper<Message>().eq(Message::getConversationId, conv.getId()));
assertEquals(1L, rows, "重试不得落库两条消息");
assertEquals(1, chatService.getUnreadCount(userB, conv.getId()), "重试不得把未读加两次");
verify(webSocketHandler, times(1))
.sendEvent(eq(userB), eq(ChatWebSocketHandler.EVENT_MESSAGE_NEW), any());
}
@Test
@DisplayName("Gate: 幂等由数据库唯一索引兜底 —— 绕过 service 的并发重放无法落两条")
void duplicateClientMessageIdRejectedByDbUniqueIndex() {
chatService.sendText(conv.getId(), userA, "第一条", "cmid-uniq");
// 直接落库,模拟"service 幂等查询之后、insert 之前"被并发插队的情形:
// 生产唯一索引 uk_message_client_id(sender_id, client_message_id) 必须拦住它。
Message dup = new Message();
dup.setConversationId(conv.getId());
dup.setSenderId(userA);
dup.setClientMessageId("cmid-uniq");
dup.setMessageType(Constants.MESSAGE_TYPE_TEXT);
dup.setContent("绕过幂等查询的重复消息");
dup.setStatus(Constants.MESSAGE_STATUS_SENT);
org.springframework.dao.DataIntegrityViolationException ex = assertThrows(
org.springframework.dao.DataIntegrityViolationException.class,
() -> messageMapper.insert(dup),
"数据库唯一索引必须拦住重复的 clientMessageId");
assertNotNull(ex.getMessage());
Long rows = messageMapper.selectCount(
new LambdaQueryWrapper<Message>().eq(Message::getConversationId, conv.getId()));
assertEquals(1L, rows, "重复消息不得落库");
}
@Test
@DisplayName("无幂等键的系统消息(RiskNotice)可多条共存,不被唯一索引误伤")
void systemMessagesWithoutClientMessageIdAreNotBlocked() {
Message a = new Message();
a.setConversationId(conv.getId());
a.setMessageType(Constants.MESSAGE_TYPE_SYSTEM);
a.setContent("{\"kind\":\"risk_notice\"}");
a.setStatus(Constants.MESSAGE_STATUS_SENT);
messageMapper.insert(a);
Message b = new Message();
b.setConversationId(conv.getId());
b.setMessageType(Constants.MESSAGE_TYPE_SYSTEM);
b.setContent("{\"kind\":\"risk_notice\"}");
b.setStatus(Constants.MESSAGE_STATUS_SENT);
assertDoesNotThrow(() -> messageMapper.insert(b));
Long rows = messageMapper.selectCount(
new LambdaQueryWrapper<Message>().eq(Message::getConversationId, conv.getId()));
assertEquals(2L, rows);
}
@Test
@DisplayName("REST send 后向对方推送 message.new,信封含 conversationId 与消息体")
void sendPushesMessageNewToPeer() {
chatService.sendText(conv.getId(), userA, "周末有空吗", "cmid-push");
var captor = org.mockito.ArgumentCaptor.forClass(Object.class);
verify(webSocketHandler).sendEvent(eq(userB), eq(ChatWebSocketHandler.EVENT_MESSAGE_NEW), captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) captor.getValue();
assertEquals(conv.getId(), data.get("conversationId"));
@SuppressWarnings("unchecked")
Map<String, Object> message = (Map<String, Object>) data.get("message");
// messageId 供小程序 messageStore 去重,sentAt 供聊天页时间分隔(前端契约字段名)
assertNotNull(message.get("messageId"));
assertEquals("周末有空吗", message.get("content"));
assertEquals(userA, message.get("senderId"));
assertNotNull(message.get("sentAt"));
}
@Test
@DisplayName("未读计数随发送递增、标记已读后清零")
void unreadCountFollowsSendAndRead() {
chatService.sendText(conv.getId(), userA, "第一条", "c1");
chatService.sendText(conv.getId(), userA, "第二条", "c2");
assertEquals(2, chatService.getUnreadCount(userB, conv.getId()));
assertEquals(0, chatService.getUnreadCount(userA, conv.getId()), "发送方不应被计未读");
List<Message> recent = chatService.loadMessages(conv.getId(), 10, 0);
chatService.markAsRead(conv.getId(), userB, recent.get(0).getId());
assertEquals(0, chatService.getUnreadCount(userB, conv.getId()), "标记已读后应清零");
}
// ==================== 2. 非 active Match 禁聊 ====================
@Test
@DisplayName("Gate: Match 已 unmatched 时服务端拒绝发消息(不依赖会话状态是否漂移)")
void sendRejectedWhenMatchNotAlive() {
// 刻意只改 Match、不改会话 —— 复现"会话状态漂移"场景,验证校验不依赖 conversation.status
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
matchMapper.updateById(match);
assertEquals(Constants.CONVERSATION_STATUS_ACTIVE,
conversationMapper.findById(conv.getId()).orElseThrow().getStatus(),
"前置条件:会话状态仍为 active");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> chatService.sendText(conv.getId(), userA, "还能发吗", "cmid-alive"));
assertTrue(ex.getMessage().contains("匹配已解除"), ex.getMessage());
verify(webSocketHandler, never())
.sendEvent(anyString(), eq(ChatWebSocketHandler.EVENT_MESSAGE_NEW), any());
}
@Test
@DisplayName("Unmatch 端点调用后即时禁聊(canonical 路径)")
void unmatchEndpointImmediatelyDisablesChat() {
// 先确认可发
assertDoesNotThrow(() -> chatService.sendText(conv.getId(), userA, "匹配中", "before-unmatch"));
var resp = matchController.unmatch(match.getId(), new UserPrincipal(userA));
assertNotNull(resp);
// Match 状态已落库
assertEquals(Constants.MATCH_STATUS_UNMATCHED,
matchMapper.findById(match.getId()).orElseThrow().getStatus());
// 双方都发不了了
assertThrows(IllegalStateException.class,
() -> chatService.sendText(conv.getId(), userB, "解除后还能发吗", "after-unmatch-a"));
assertThrows(IllegalStateException.class,
() -> chatService.sendText(conv.getId(), userA, "解除后还能发吗", "after-unmatch-b"));
// 并且双方都收到了 conversation.closed 通知
verify(webSocketHandler, atLeastOnce())
.sendEvent(anyString(), eq(ChatWebSocketHandler.EVENT_CONVERSATION_CLOSED), any());
}
@Test
@DisplayName("Block 后即时禁聊,且 Block 必须把 Match 落库为 unmatched(回归守卫)")
void blockDisablesChatAndPersistsUnmatch() {
relationService.block(userA, userB);
// 历史缺陷:MatchService.unmatch 只改内存态,Block 后 Match 仍是 active
assertEquals(Constants.MATCH_STATUS_UNMATCHED,
matchMapper.findById(match.getId()).orElseThrow().getStatus(),
"Block 必须把 Match 状态持久化为 unmatched");
assertThrows(IllegalStateException.class,
() -> chatService.sendText(conv.getId(), userA, "拉黑后还能发吗", "cmid-block"));
verify(webSocketHandler, atLeastOnce())
.sendEvent(anyString(), eq(ChatWebSocketHandler.EVENT_CONVERSATION_CLOSED), any());
}
// ==================== 3. 活动双确认复用同一消息体系 ====================
@Test
@DisplayName("Gate: 未形成可聊 Match 时无法建立会话(活动未双确认时正是此状态)")
void conversationCannotBeCreatedWithoutActiveMatch() {
// ① 活动未双确认:双方之间不存在任何 Match,自然没有可供发消息的会话
assertTrue(matchMapper.findActiveBetween(userA, outsider).isEmpty(),
"活动未双确认时不应存在 Match");
// ② 不存在的 Match 不能建会话
assertThrows(IllegalArgumentException.class,
() -> chatService.getOrCreateConversation("match-not-exist", userA, outsider));
// ③ 已失效的 Match(活动被取消 / 已 Unmatch)不能建会话
Match dead = createMatch(userA, outsider, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL,
Constants.MATCH_STATUS_UNMATCHED);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> chatService.getOrCreateConversation(dead.getId(), userA, outsider));
assertTrue(ex.getMessage().contains("已失效"), ex.getMessage());
}
@Test
@DisplayName("活动双确认产生的 Match/Conversation 复用同一套发消息与推送逻辑")
void activityMutualConversationReusesSameMessagingPipeline() throws Exception {
// 前置:先解除 user_like 的 Match,模拟"此前未匹配过"
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
matchMapper.updateById(match);
// 1. 活动双方分别确认 → 创建 activity_mutual 的 Match Episode(不自动建会话)
Map<String, Object> result = relationshipService.confirmActivityInterest(userA, userB);
assertEquals(Boolean.TRUE, result.get("matchCreated"));
Match activityMatch = matchMapper.findActiveBetween(userA, userB).orElseThrow();
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, activityMatch.getSource());
// 2. 通过规范端点按 Match 取会话(复用同一 Conversation 体系,与 source 无关)
String token = jwtTokenService.generateAccessToken(userA, null);
String body = mockMvc.perform(post("/api/v1/conversations/from-match/" + activityMatch.getId())
.contextPath("/api/v1")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.conversationId").exists())
.andReturn().getResponse().getContentAsString();
assertTrue(body.contains(activityMatch.getId()));
Conversation activityConv = conversationMapper.findByMatchId(activityMatch.getId()).orElseThrow();
assertNotEquals(conv.getId(), activityConv.getId(), "新 Match 应有独立会话");
// 3. 同一套发送 + 推送逻辑对活动来源同样生效
chatService.sendText(activityConv.getId(), userA, "活动认识,你好", "cmid-activity");
verify(webSocketHandler, times(1))
.sendEvent(eq(userB), eq(ChatWebSocketHandler.EVENT_MESSAGE_NEW), any());
assertEquals(1, chatService.getUnreadCount(userB, activityConv.getId()));
// 清理活动会话
cleanupConversation(activityConv.getId());
}
// ==================== 4. 对象级鉴权(IDOR) ====================
@Test
@DisplayName("非会话成员读取他人聊天记录被拒(此前任意登录用户可读)")
void outsiderCannotReadMessages() throws Exception {
chatService.sendText(conv.getId(), userA, "私密内容", "cmid-private");
String outsiderToken = jwtTokenService.generateAccessToken(outsider, null);
String body = mockMvc.perform(get("/api/v1/conversations/" + conv.getId() + "/messages")
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
// 契约:控制器层以 body 级错误码表达"不可见"(success=false + code=NOT_FOUND),
// 与 MatchController.authorizedMatch 的既有约定一致;HTTP 层仍是 200。
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"))
.andReturn().getResponse().getContentAsString();
assertFalse(body.contains("私密内容"), "越权响应不得泄露聊天正文: " + body);
}
@Test
@DisplayName("会话成员可正常读取消息(鉴权不得误伤本人)")
void memberCanReadMessages() throws Exception {
chatService.sendText(conv.getId(), userA, "正常内容", "cmid-member");
String token = jwtTokenService.generateAccessToken(userB, null);
mockMvc.perform(get("/api/v1/conversations/" + conv.getId() + "/messages")
.contextPath("/api/v1")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.items[0].content").value("正常内容"));
}
@Test
@DisplayName("非成员读取会话详情/未读数同样被拒,成员不受影响")
void outsiderCannotReadConversationDetailNorUnread() throws Exception {
String outsiderToken = jwtTokenService.generateAccessToken(outsider, null);
mockMvc.perform(get("/api/v1/conversations/" + conv.getId())
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
mockMvc.perform(get("/api/v1/conversations/" + conv.getId() + "/unread")
.contextPath("/api/v1")
.header("Authorization", "Bearer " + outsiderToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
// 成员侧不受影响
String memberToken = jwtTokenService.generateAccessToken(userA, null);
mockMvc.perform(get("/api/v1/conversations/" + conv.getId())
.contextPath("/api/v1")
.header("Authorization", "Bearer " + memberToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
// ==================== 辅助 ====================
private void cleanupConversation(String conversationId) {
messageMapper.delete(new LambdaQueryWrapper<Message>().eq(Message::getConversationId, conversationId));
memberMapper.findByConversation(conversationId).forEach(m ->
memberMapper.delete(new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.ConversationMember>()
.eq(com.danyang.match.platform.chat.entity.ConversationMember::getConversationId, conversationId)
.eq(com.danyang.match.platform.chat.entity.ConversationMember::getUserId, m.getUserId())));
conversationMapper.deleteById(conversationId);
}
private String createUser(String name) {
User u = new User();
u.setWechatOpenid("chat09_" + name + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
userMapper.insert(u);
return u.getId();
}
private Match createMatch(String a, String b, String source, String status) {
Match m = new Match();
m.setUserAId(a.compareTo(b) < 0 ? a : b);
m.setUserBId(a.compareTo(b) < 0 ? b : a);
m.setSource(source);
m.setStatus(status);
m.setEpisode(1);
m.setMatchedAt(Instant.now());
matchMapper.insert(m);
return m;
}
}
@@ -0,0 +1,152 @@
package com.danyang.match.platform.chat.websocket;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* WebSocket 下行推送单元测试(不依赖 Spring 上下文,毫秒级)。
*
* <p>覆盖 Phase 09 的 "WebSocket receive" 侧:
* <ul>
* <li>握手阶段写入的 userId 能把连接归属到正确用户(历史缺陷:无处写 userId,
* 导致 sessions 恒空、推送全部静默丢失);</li>
* <li>下行事件信封符合《REST API 接口详细设计规范》第 125 章 {@code {event, data}};</li>
* <li>未认证连接不得留存(规范第 126 章)。</li>
* </ul>
*/
class ChatWebSocketHandlerTest {
private ChatWebSocketHandler handler;
@BeforeEach
void setUp() {
handler = new ChatWebSocketHandler();
}
private WebSocketSession sessionOf(String sessionId, String userId) {
WebSocketSession session = mock(WebSocketSession.class);
when(session.getId()).thenReturn(sessionId);
when(session.isOpen()).thenReturn(true);
Map<String, Object> attrs = new HashMap<>();
if (userId != null) {
attrs.put(ChatHandshakeInterceptor.ATTR_USER_ID, userId);
}
when(session.getAttributes()).thenReturn(attrs);
return session;
}
@Test
@DisplayName("握手写入的 userId 决定推送路由:只推给对应用户")
void sendEventRoutesByHandshakeUserId() throws Exception {
WebSocketSession alice = sessionOf("s-alice", "alice");
WebSocketSession bob = sessionOf("s-bob", "bob");
handler.afterConnectionEstablished(alice);
handler.afterConnectionEstablished(bob);
handler.sendEvent("alice", ChatWebSocketHandler.EVENT_MESSAGE_NEW, Map.of("conversationId", "c1"));
verify(alice, times(1)).sendMessage(any(TextMessage.class));
verify(bob, never()).sendMessage(any(TextMessage.class));
}
@Test
@DisplayName("下行信封符合规范第125章 {event, data} 结构")
void envelopeMatchesSpec() throws Exception {
WebSocketSession alice = sessionOf("s-alice", "alice");
handler.afterConnectionEstablished(alice);
handler.sendEvent("alice", ChatWebSocketHandler.EVENT_MESSAGE_NEW,
Map.of("conversationId", "conv-1", "message", Map.of("messageId", "msg-1", "content", "你好")));
var captor = org.mockito.ArgumentCaptor.forClass(TextMessage.class);
verify(alice).sendMessage(captor.capture());
String payload = captor.getValue().getPayload();
assertTrue(payload.contains("\"event\":\"message.new\""), "必须带规范事件名: " + payload);
assertTrue(payload.contains("\"data\":{"), "必须包在 data 里: " + payload);
assertTrue(payload.contains("\"conversationId\":\"conv-1\""), payload);
assertTrue(payload.contains("\"messageId\":\"msg-1\""), payload);
}
@Test
@DisplayName("会话关闭事件名符合规范第124章 conversation.closed")
void conversationClosedEventName() throws Exception {
WebSocketSession alice = sessionOf("s-alice", "alice");
handler.afterConnectionEstablished(alice);
handler.sendEvent("alice", ChatWebSocketHandler.EVENT_CONVERSATION_CLOSED,
Map.of("conversationId", "conv-1", "status", "blocked"));
var captor = org.mockito.ArgumentCaptor.forClass(TextMessage.class);
verify(alice).sendMessage(captor.capture());
assertEquals("conversation.closed", ChatWebSocketHandler.EVENT_CONVERSATION_CLOSED);
assertTrue(captor.getValue().getPayload().contains("\"event\":\"conversation.closed\""));
}
@Test
@DisplayName("无 userId 的连接被拒绝并关闭(规范第126章:连接必须绑定 userId)")
void unauthenticatedConnectionIsRejected() throws Exception {
WebSocketSession anonymous = sessionOf("s-anon", null);
handler.afterConnectionEstablished(anonymous);
assertEquals(0, handler.onlineCount(), "未认证连接不得进入在线表");
verify(anonymous).close(any(CloseStatus.class));
}
@Test
@DisplayName("断开后不再推送(避免向已关闭连接写数据)")
void noPushAfterDisconnect() throws Exception {
WebSocketSession alice = sessionOf("s-alice", "alice");
handler.afterConnectionEstablished(alice);
handler.afterConnectionClosed(alice, CloseStatus.NORMAL);
handler.sendEvent("alice", ChatWebSocketHandler.EVENT_MESSAGE_NEW, Map.of("conversationId", "c1"));
verify(alice, never()).sendMessage(any(TextMessage.class));
}
@Test
@DisplayName("同用户重连:新连接接管推送,旧连接被关闭(避免重复投递)")
void reconnectReplacesOldSession() throws Exception {
WebSocketSession first = sessionOf("s-first", "alice");
WebSocketSession second = sessionOf("s-second", "alice");
handler.afterConnectionEstablished(first);
handler.afterConnectionEstablished(second);
handler.sendEvent("alice", ChatWebSocketHandler.EVENT_MESSAGE_NEW, Map.of("conversationId", "c1"));
verify(second, times(1)).sendMessage(any(TextMessage.class));
verify(first, never()).sendMessage(any(TextMessage.class));
verify(first).close(any(CloseStatus.class));
assertEquals(1, handler.onlineCount());
}
@Test
@DisplayName("上行不处理订阅指令,仅 ping 回 pong(规范第126章:不允许客户端自行订阅)")
void uplinkOnlySupportsPing() throws Exception {
WebSocketSession alice = sessionOf("s-alice", "alice");
handler.afterConnectionEstablished(alice);
handler.handleTextMessage(alice, new TextMessage("{\"event\":\"subscribe\",\"conversationId\":\"c-other\"}"));
verify(alice, never()).sendMessage(any(TextMessage.class));
handler.handleTextMessage(alice, new TextMessage("ping"));
var captor = org.mockito.ArgumentCaptor.forClass(TextMessage.class);
verify(alice).sendMessage(captor.capture());
assertTrue(captor.getValue().getPayload().contains("\"event\":\"pong\""));
}
}
+9
View File
@@ -55,6 +55,15 @@ CREATE TABLE messages (
message_type VARCHAR(32), content CLOB, file_id VARCHAR(36), status VARCHAR(32), risk_level VARCHAR(16),
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
);
-- 与 V004__add_messaging_and_recommendation_tables.sql:71 的生产唯一索引对齐:
-- CREATE UNIQUE INDEX uk_message_client_id ON messages(sender_id, client_message_id)
-- WHERE client_message_id IS NOT NULL;
-- 生产带 WHERE(部分索引),H2 不支持过滤索引;但 H2 的唯一索引对 NULL 采取
-- SQL 标准语义(多个 NULL 互不冲突),因此省略 WHERE 后语义等价,
-- 保证「未带幂等键的消息」(如系统 RiskNotice)仍可多条存在。
-- 这条索引是"正常会话不重复投递"在并发下的真正保障:service 侧的
-- SELECT-then-INSERT 在并发重放时会有竞态,只有 DB 唯一索引能兜住。
CREATE UNIQUE INDEX uk_message_client_id ON messages(sender_id, client_message_id);
CREATE TABLE audit_logs (
id VARCHAR(36) PRIMARY KEY, operator_id VARCHAR(36), organization_id VARCHAR(36), action VARCHAR(128),
resource_type VARCHAR(64), resource_id VARCHAR(128), before_data CLOB, after_data CLOB, ip VARCHAR(64),