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:
+451
@@ -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;
|
||||
}
|
||||
}
|
||||
+152
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user