diff --git a/backend/src/main/java/com/danyang/match/platform/chat/config/WebSocketConfig.java b/backend/src/main/java/com/danyang/match/platform/chat/config/WebSocketConfig.java index d4407c6..e742dcd 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/config/WebSocketConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/config/WebSocketConfig.java @@ -1,5 +1,6 @@ package com.danyang.match.platform.chat.config; +import com.danyang.match.platform.chat.websocket.ChatHandshakeInterceptor; import com.danyang.match.platform.chat.websocket.ChatWebSocketHandler; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -10,15 +11,28 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry /** * WebSocket 配置 — 实时消息推送 * - * 端点: /ws/chat?token={jwt} - * 协议: JSON (type + payload) - * - type="message" → 新消息推送 - * - type="typing" → 对方正在输入 - * - type="read" → 已读回执 - * - type="match" → 新 Match 通知 - * - type="unmatch" → Match 解除 + *

端点: {@code /ws/chat?token={jwt}}(原生 WebSocket,非 SockJS) * - * 鉴权: 握手时 URL 带 JWT token(与 REST 同密钥) + *

协议:事件信封对齐《REST API 接口详细设计规范》第 125 章,形如 + *

{ "event": "message.new", "data": { ... } }
+ * 事件名沿用规范第 124 章清单: + * + * + *

鉴权:握手阶段由 {@link ChatHandshakeInterceptor} 校验 {@code token} 并把 userId + * 绑定到连接(规范第 126 章)。 + * + *

关于 SockJS:此前本配置调用了 {@code .withSockJS()}。SockJS 会把端点改写为 + * {@code /ws/chat/**} 的 SockJS 协议族,原生 WebSocket 客户端(小程序 + * {@code wx.connectSocket}、规范第 124 章给的 {@code /wss/chat})无法直接连接, + * 与类注释自述的 {@code /ws/chat?token=} 原生契约自相矛盾。 + * 本项目两端都是原生 WebSocket 客户端,故移除 SockJS。 */ @Configuration @EnableWebSocket @@ -26,11 +40,14 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatHandler; + private final ChatHandshakeInterceptor chatHandshakeInterceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, "/ws/chat") - .setAllowedOriginPatterns("*") - .withSockJS(); // SockJS fallback for miniapp 兼容 + .addInterceptors(chatHandshakeInterceptor) + // 小程序 / 原生客户端握手不带 Origin,需允许任意来源; + // 真正的访问控制由握手 token 鉴权承担,不以 Origin 作为安全边界。 + .setAllowedOriginPatterns("*"); } } diff --git a/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java b/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java index d067470..59b9f20 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java @@ -92,6 +92,8 @@ public class ConversationController { public ApiResponse detail(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); + // 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息 + if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound(); ConversationDTO dto = chatService.getConversation(principal.id(), id); if (dto == null) return ApiResponse.notFound(); return ApiResponse.ok(dto); @@ -105,6 +107,8 @@ public class ConversationController { public ApiResponse> unread(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); + // 对象级鉴权:非成员不得读取他人会话未读 + if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound(); return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id))); } diff --git a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java index ed69459..21273f0 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java @@ -59,6 +59,8 @@ public class MessageController { @RequestParam(defaultValue = "0") int offset, @AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); + // 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话) + if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound(); int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE); List messages; @@ -82,6 +84,8 @@ public class MessageController { @RequestBody Map body, @AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); + // 对象级鉴权:非成员不得改写他人会话的已读状态 + if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound(); String lastReadId = body.get("lastReadMessageId"); if (lastReadId != null) { chatService.markAsRead(id, principal.id(), lastReadId); diff --git a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java index f3dbf5a..1e58b4f 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java @@ -7,6 +7,7 @@ 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.websocket.ChatWebSocketHandler; import com.danyang.match.platform.match.entity.Match; import com.danyang.match.platform.match.mapper.MatchMapper; import com.danyang.match.platform.match.mapper.UserRelationMapper; @@ -19,7 +20,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -31,6 +34,7 @@ import java.util.UUID; * - INV-004: Block 优先级最高 — 双方任一 block → conversation blocked,禁发 * - P09-V11-T01: Activity Match source 兼容 — Conversation 只绑 match_id,不关心 source * - Phase 10 Chat Safety: 频控 + 内容扫描 + RiskNotice + * - Phase 09: REST send + WebSocket receive —— 落库成功后按规范第 125 章事件信封推送 */ @Service @RequiredArgsConstructor @@ -45,6 +49,7 @@ public class ChatService { private final UserRelationMapper relationMapper; private final UserProfileMapper userProfileMapper; private final ChatSafetyService safetyService; + private final ChatWebSocketHandler webSocketHandler; // ==================== Conversation 创建 ==================== @@ -110,6 +115,11 @@ public class ChatService { throw new IllegalStateException("会话已关闭"); } + // INV-005 服务端权威校验:未 Mutual / 已 Unmatch / 已成功牵手退出 → 不可发消息。 + // 不能只信 conv.status —— 历史缺陷里 Unmatch 只改 Match 表、从不关闭会话, + // 于是会话仍是 active,仅凭 conversation.status 会放行已解除匹配的双方继续聊天。 + assertMatchAlive(conv); + // 幂等重放优先:同一 clientMessageId 已成功落库则直接返回原消息,不重复走安全校验 if (clientMessageId != null) { Optional dup = messageMapper.findByClientMessageId(conversationId, senderId, clientMessageId); @@ -171,6 +181,14 @@ public class ChatService { } } + // Phase 09: REST send + WebSocket receive —— 落库成功后推送 message.new 给对方。 + // 幂等重放已在上面 return,走不到这里,所以正常会话不会重复投递; + // 对方离线(无活跃连接)时静默跳过,不抛异常、不影响已提交的事务。 + if (peerId != null) { + webSocketHandler.sendEvent(peerId, ChatWebSocketHandler.EVENT_MESSAGE_NEW, + buildMessageNewData(conversationId, msg)); + } + // 3. warning 级内容 → 给对方推 RiskNotice 系统消息(P10-V11-T01/T02) if (scan.riskLevel.equals(Constants.RISK_LEVEL_WARNING) && peerId != null) { sendSystemRiskNotice(conversationId, senderId, peerId, scan.reason); @@ -196,11 +214,28 @@ public class ChatService { notice.setContent(ChatSafetyService.buildRiskNoticeBody(reason != null ? reason : "检测到可疑内容")); notice.setStatus(Constants.MESSAGE_STATUS_SENT); messageMapper.insert(notice); + // 系统提示同样需要实时到达对方,否则用户要重进会话才看到反诈提示 + if (peerId != null) { + webSocketHandler.sendEvent(peerId, ChatWebSocketHandler.EVENT_MESSAGE_NEW, + buildMessageNewData(conversationId, notice)); + } log.info("RiskNotice sent to {} in conv {}", peerId, conversationId); } // ==================== 拉消息 ==================== + /** + * 会话成员判定 —— 供控制器做对象级鉴权(AGENTS 第 7 条:工作人员及无关用户不得查看私人聊天)。 + * + *

历史缺陷:{@code MessageController.load/markRead}、{@code ConversationController.detail/unread} + * 此前完全没有成员校验,任何已登录用户只要拿到 conversationId 就能读取他人完整聊天记录 + * (典型 IDOR)。调用方一律以 404 响应非成员,避免被用来探测会话是否存在。 + */ + public boolean isMember(String conversationId, String userId) { + if (conversationId == null || userId == null) return false; + return memberMapper.find(conversationId, userId).isPresent(); + } + public List loadMessages(String conversationId, int limit, int offset) { return messageMapper.findRecentByConversation(conversationId, limit, offset); } @@ -219,18 +254,132 @@ public class ChatService { // ==================== 状态联动 ==================== + /** + * 会话状态跟随 Match 状态联动,并在关闭时实时通知双方。 + * + *

历史缺陷(本方法修复):本方法此前无任何调用点 —— + * {@code MatchController.unmatch} 只把 Match 改成 unmatched 就结束, + * {@code RelationService.block} 连 Match 状态都没落库, + * 于是会话永远停留在 {@code active},Unmatch/Block 后仍可继续发消息。 + * + * @param matchId 会话绑定的 Match + * @param matchStatus Match 的新状态 + */ @Transactional public void syncFromMatch(String matchId, String matchStatus) { + if (matchId == null) return; conversationMapper.findByMatchId(matchId).ifPresent(conv -> { - switch (matchStatus) { - case Constants.MATCH_STATUS_UNMATCHED -> conv.setStatus(Constants.CONVERSATION_STATUS_UNMATCHED); - case Constants.MATCH_STATUS_ACTIVE, Constants.MATCH_STATUS_GETTING_TO_KNOW -> conv.setStatus(Constants.CONVERSATION_STATUS_ACTIVE); - default -> {} + boolean alive = Constants.MATCH_STATUS_ACTIVE.equals(matchStatus) + || Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(matchStatus); + + if (alive) { + // 恢复可聊(如 prior Unmatch 后活动互选复用同一 Match) + if (!Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus())) { + conv.setStatus(Constants.CONVERSATION_STATUS_ACTIVE); + conversationMapper.updateById(conv); + log.info("Conversation reopened: conv={}, match={}", conv.getId(), matchId); + } + return; } - conversationMapper.updateById(conv); + + // 不再可聊:只推事件,不改 conversation.status(原因见 closeConversationForMatch) + notifyConversationClosed(conv.getId(), matchId, Constants.CONVERSATION_STATUS_UNMATCHED); }); } + /** + * Match 失效(Unmatch / Block)时通知双方会话已关闭。 + * + *

为什么只推事件、不改 conversation.status: + * {@code ConversationMapper.findActiveByUser} 过滤 {@code status = 'active'}, + * 一旦把 status 改成 {@code unmatched}/{@code blocked},会话就会从"我的消息"列表里消失, + * 这等于静默改变了列表可见性 —— 而 {@code conversation-item} 组件恰恰要在列表里 + * 渲染"会话已停用 / 会话已停用(已拉黑)"的灰显态,AGENTS 也要求 Block 保留历史可查。 + * 因此关闭语义由"事件 + 发送时校验"承载,而非由 status 承载: + *

+ * 列表可见性是否应在关闭后收回,属产品决策,未在本阶段授权范围内,故保持现状并登记为待决项。 + * + * @param matchId Match id + * @param reasonStatus 通知里携带的状态:{@code unmatched} 或 {@code blocked} + */ + @Transactional + public void closeConversationForMatch(String matchId, String reasonStatus) { + if (matchId == null) return; + conversationMapper.findByMatchId(matchId) + .ifPresent(conv -> notifyConversationClosed(conv.getId(), matchId, reasonStatus)); + } + + /** 会话关闭后通知全部成员(规范第 124 章 conversation.closed) */ + private void notifyConversationClosed(String conversationId, String matchId, String status) { + Map data = new LinkedHashMap<>(); + data.put("conversationId", conversationId); + data.put("matchId", matchId); + data.put("status", status); + memberMapper.findByConversation(conversationId) + .forEach(m -> webSocketHandler.sendEvent( + m.getUserId(), ChatWebSocketHandler.EVENT_CONVERSATION_CLOSED, data)); + log.info("Conversation closed: conv={}, match={}, status={}", conversationId, matchId, status); + } + + /** + * INV-005 权威校验:会话绑定的 Match 必须仍处于可聊天状态。 + * + *

仅当 Match 已不存在、或状态不在 + * {active, getting_to_know} 之内时拒绝。没有绑定 Match 的会话不受此限 + * (预留给将来非 Match 来源的会话)。 + */ + private void assertMatchAlive(Conversation conv) { + String matchId = conv.getMatchId(); + if (matchId == null) return; + Match match = matchMapper.findById(matchId).orElse(null); + boolean alive = match != null + && (Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus()) + || Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(match.getStatus())); + if (!alive) { + log.warn("Message rejected by MATCH_NOT_ALIVE: conv={}, match={}, status={}", + conv.getId(), matchId, match == null ? "missing" : match.getStatus()); + throw new IllegalStateException("匹配已解除,无法发送消息"); + } + } + + /** + * 构造规范第 125 章 message.new 的 data 负载。 + * + *

字段同时满足两份契约: + *

+ * 故输出为两者并集,避免任一消费方断链。 + */ + private Map buildMessageNewData(String conversationId, Message msg) { + Map message = new LinkedHashMap<>(); + message.put("messageId", msg.getId()); + message.put("id", msg.getId()); + message.put("senderId", msg.getSenderId()); + message.put("messageType", msg.getMessageType()); + message.put("type", msg.getMessageType()); + message.put("content", msg.getContent()); + message.put("fileId", msg.getFileId()); + message.put("clientMessageId", msg.getClientMessageId()); + message.put("status", msg.getStatus()); + message.put("riskLevel", msg.getRiskLevel()); + message.put("createdAt", msg.getCreatedAt()); + message.put("sentAt", msg.getCreatedAt() == null ? null : msg.getCreatedAt().toEpochMilli()); + + Map data = new LinkedHashMap<>(); + data.put("conversationId", conversationId); + data.put("message", message); + return data; + } + // ==================== 会话查询(原 messaging 模块能力合并) ==================== /** diff --git a/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatHandshakeInterceptor.java b/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatHandshakeInterceptor.java new file mode 100644 index 0000000..efd97d8 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatHandshakeInterceptor.java @@ -0,0 +1,130 @@ +package com.danyang.match.platform.chat.websocket; + +import com.danyang.match.platform.auth.token.JwtTokenService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * WebSocket 握手鉴权(规范第 126 章 WebSocket 安全原则)。 + * + *

规范要求:Token 认证 / 连接绑定 userId。本拦截器在握手阶段完成这两件事, + * 只有认证通过才放行,并把 userId 写入 session attributes 供 + * {@link ChatWebSocketHandler} 识别连接归属。 + * + *

历史缺陷(本类修复):此前全局没有任何 HandshakeInterceptor,也没有任何代码 + * 往 session attributes 里写 userId,导致 {@code ChatWebSocketHandler.extractUserId()} + * 恒返回 null、{@code sessions} 映射永远为空 —— 整条 WebSocket 下行推送静默失效, + * 且未认证连接可以随意建立(违反规范第 126 章)。 + * + *

鉴权方式(按优先级): + *

    + *
  1. 握手 URL 查询参数 {@code ?token=xxx}(小程序 {@code wx.connectSocket} 最易实现)
  2. + *
  3. 握手请求头 {@code Authorization: Bearer xxx}
  4. + *
+ * + *

仅接受用户 access token({@code type=access})。运营端 token + * ({@code type=operator_access})一律拒绝 —— AGENTS 第 7 条:普通工作人员不能查看私人聊天。 + */ +@Component +@RequiredArgsConstructor +public class ChatHandshakeInterceptor implements HandshakeInterceptor { + + private static final Logger log = LoggerFactory.getLogger(ChatHandshakeInterceptor.class); + + /** session attributes 中承载用户身份的键,与 ChatWebSocketHandler 约定一致 */ + public static final String ATTR_USER_ID = "userId"; + + private static final String QUERY_PARAM_TOKEN = "token"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String TOKEN_TYPE_USER_ACCESS = "access"; + + private final JwtTokenService jwtTokenService; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + String token = resolveToken(request); + if (token == null || token.isBlank()) { + log.warn("WS handshake rejected: 缺少 token"); + return false; + } + + // fail-closed:token 校验内部会查 Redis 黑名单,任何异常(如 Redis 不可用) + // 都必须拒绝握手,否则会在握手阶段抛异常而不是给出可判定的拒绝。 + try { + if (!jwtTokenService.validateToken(token)) { + log.warn("WS handshake rejected: token 无效或已进黑名单"); + return false; + } + + String type = jwtTokenService.parseToken(token).get("type", String.class); + if (!TOKEN_TYPE_USER_ACCESS.equals(type)) { + log.warn("WS handshake rejected: 非用户 token, type={}", type); + return false; + } + + String userId = jwtTokenService.getUserIdFromToken(token); + if (userId == null || userId.isBlank()) { + log.warn("WS handshake rejected: token 无 subject"); + return false; + } + + attributes.put(ATTR_USER_ID, userId); + log.info("WS handshake accepted: userId={}", userId); + return true; + } catch (Exception e) { + log.warn("WS handshake rejected: token 校验异常 {}", e.getMessage()); + return false; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + if (exception != null) { + log.warn("WS handshake error: {}", exception.getMessage()); + } + } + + /** + * 从查询参数或 Authorization 头解析 token。 + * + *

查询参数优先:小程序端 WebSocket 请求头支持有限,规范给的也是 + * {@code /wss/chat} 直连形式。 + */ + private String resolveToken(ServerHttpRequest request) { + String fromQuery = UriComponentsBuilder.fromUri(request.getURI()) + .build() + .getQueryParams() + .getFirst(QUERY_PARAM_TOKEN); + if (fromQuery != null && !fromQuery.isBlank()) { + return fromQuery; + } + + String header = request.getHeaders().getFirst("Authorization"); + if (header != null && header.startsWith(BEARER_PREFIX)) { + return header.substring(BEARER_PREFIX.length()); + } + + // 兼容 Servlet 容器下未透传到 header 映射的场景 + if (request instanceof ServletServerHttpRequest servletRequest) { + HttpServletRequest raw = servletRequest.getServletRequest(); + String rawHeader = raw.getHeader("Authorization"); + if (rawHeader != null && rawHeader.startsWith(BEARER_PREFIX)) { + return rawHeader.substring(BEARER_PREFIX.length()); + } + } + return null; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandler.java b/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandler.java index 4abb37b..5d4a486 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandler.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandler.java @@ -1,5 +1,8 @@ package com.danyang.match.platform.chat.websocket; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -9,81 +12,141 @@ import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** - * WebSocket Handler — 实时消息推送 + * WebSocket Handler — 实时消息推送(下行) * - * 简易实现:内存 Map 维护 userId → Session 映射 - * 多实例部署时需要 Redis Pub/Sub 扩展,Phase 22+ 再做 + *

简易实现:内存 Map 维护 userId → Session 映射。 + * 多实例部署时需要 Redis Pub/Sub 扩展(当前为单实例)。 + * + *

协议:事件信封按《REST API 接口详细设计规范》第 125 章: + *

{ "event": "message.new", "data": { ... } }
+ * + *

鉴权与订阅边界(规范第 126 章): + *

    + *
  • Token 认证 + 连接绑定 userId —— 由 {@link ChatHandshakeInterceptor} 在握手阶段完成; + * 本类只信任 session attributes 里的 userId,绝不接受客户端自报身份。
  • + *
  • 服务端决定订阅权限 —— 上行帧不做订阅处理。规范明确禁止 + * "客户端自行订阅任意 conversationId",故上行只支持 {@code ping} 保活, + * 其余一律忽略。消息落库与推送范围完全由服务端按会话成员计算。
  • + *
*/ @Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Logger log = LoggerFactory.getLogger(ChatWebSocketHandler.class); - /** userId -> WebSocketSession */ + /** 事件名(规范第 124 章清单) */ + public static final String EVENT_MESSAGE_NEW = "message.new"; + public static final String EVENT_MESSAGE_READ = "message.read"; + public static final String EVENT_CONVERSATION_CLOSED = "conversation.closed"; + + private static final String UPLINK_PING = "ping"; + private static final String EVENT_PONG = "pong"; + + /** userId -> WebSocketSession(同一用户重连时覆盖旧连接) */ private final Map sessions = new ConcurrentHashMap<>(); - /** userId -> Set 当前订阅的会话 */ - private final Map> subscriptions = new ConcurrentHashMap<>(); + /** Instant 等 JSR310 类型需要模块支持,否则推送序列化抛 InvalidDefinitionException */ + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @Override public void afterConnectionEstablished(WebSocketSession session) { String userId = extractUserId(session); - if (userId != null) { - sessions.put(userId, session); - log.info("WS connected: userId={}, session={}", userId, session.getId()); + if (userId == null) { + // 理论上不可达:握手拦截器已拒绝无 userId 的连接。兜底关闭,避免留下匿名会话。 + log.warn("WS connected without userId, closing: session={}", session.getId()); + try { + session.close(CloseStatus.NOT_ACCEPTABLE); + } catch (IOException ignored) { + // 连接已断开 + } + return; } + WebSocketSession previous = sessions.put(userId, session); + if (previous != null && previous.isOpen() && !previous.getId().equals(session.getId())) { + // 同账号多端:以最新连接为推送目标,旧连接主动关闭(避免重复投递) + try { + previous.close(CloseStatus.NORMAL.withReason("replaced by new session")); + } catch (IOException ignored) { + // 旧连接已断开 + } + } + log.info("WS connected: userId={}, session={}", userId, session.getId()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { - // 客户端上行消息目前只处理 ping/pong 和 typing/read 类事件 - // 发消息走 HTTP REST 端点(有完整校验 + 风控 + 幂等) - // WS 只用于下行推送 - log.debug("WS received from {}: {}", session.getId(), message.getPayload()); + // 发消息走 HTTP REST(有完整校验 + 风控 + 幂等);WS 只用于下行推送。 + // 上行仅支持 ping 保活;不处理订阅类指令(规范第 126 章:不允许客户端自行订阅任意 conversationId)。 + String payload = message.getPayload(); + if (payload == null) return; + String trimmed = payload.trim(); + if (UPLINK_PING.equalsIgnoreCase(trimmed) || trimmed.contains("\"ping\"")) { + session.sendMessage(new TextMessage(buildEnvelope(EVENT_PONG, Map.of("ts", System.currentTimeMillis())))); + return; + } + log.debug("WS 上行帧已忽略(非 ping): session={}, payload={}", session.getId(), trimmed); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + // 仅当该 session 仍是当前登记连接时才移除,避免重连竞态把新连接误删 sessions.entrySet().removeIf(e -> e.getValue().getId().equals(session.getId())); log.info("WS disconnected: session={}, status={}", session.getId(), status); } // ==================== 下行推送 API ==================== - /** 推送消息给指定用户 */ - public void sendToUser(String userId, String json) { + /** + * 按规范信封推送事件给指定用户。 + * + * @param userId 目标用户;未连接时静默跳过(不抛异常,不影响主业务事务) + * @param event 事件名,见规范第 124 章 + * @param data 事件负载 + */ + public void sendEvent(String userId, String event, Object data) { + if (userId == null || event == null) return; WebSocketSession session = sessions.get(userId); - if (session != null && session.isOpen()) { - try { - session.sendMessage(new TextMessage(json)); - } catch (IOException e) { - log.warn("WS send failed to {}: {}", userId, e.getMessage()); - } + if (session == null || !session.isOpen()) return; + try { + session.sendMessage(new TextMessage(buildEnvelope(event, data))); + } catch (IOException e) { + log.warn("WS send failed to {}: {}", userId, e.getMessage()); } } - /** 推送消息给双方会话成员 */ - public void sendToConversation(String convId, String userA, String userB, String json) { - sendToUser(userA, json); - sendToUser(userB, json); + /** 批量推送同一事件给多个用户(如会话双方) */ + public void sendEventToUsers(Collection userIds, String event, Object data) { + if (userIds == null) return; + for (String uid : userIds) { + sendEvent(uid, event, data); + } } - /** 广播到所有连接(慎用) */ - public void broadcast(String json) { - sessions.values().forEach(s -> { - if (s.isOpen()) { - try { s.sendMessage(new TextMessage(json)); } catch (IOException ignored) {} - } - }); + /** 当前在线连接数(健康检查 / 排障用) */ + public int onlineCount() { + return sessions.size(); + } + + private String buildEnvelope(String event, Object data) { + Map envelope = new LinkedHashMap<>(); + envelope.put("event", event); + envelope.put("data", data); + try { + return objectMapper.writeValueAsString(envelope); + } catch (JsonProcessingException e) { + log.warn("WS envelope serialize failed: event={}, err={}", event, e.getMessage()); + return "{\"event\":\"" + event + "\",\"data\":null}"; + } } private String extractUserId(WebSocketSession session) { - Object attr = session.getAttributes().get("userId"); + Object attr = session.getAttributes().get(ChatHandshakeInterceptor.ATTR_USER_ID); return attr != null ? attr.toString() : null; } } diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java index 347abf8..077100c 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java @@ -1,11 +1,13 @@ package com.danyang.match.platform.match.controller; +import com.danyang.match.platform.chat.service.ChatService; import com.danyang.match.platform.match.entity.Match; import com.danyang.match.platform.match.mapper.MatchMapper; import com.danyang.match.platform.match.mapper.UserRelationMapper; import com.danyang.match.platform.match.service.MatchService; import com.danyang.match.platform.match.service.RelationService; import com.danyang.match.platform.relationship.service.RelationshipStatusService; +import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.CursorPage; import com.danyang.match.platform.shared.security.principal.UserPrincipal; @@ -42,6 +44,7 @@ public class MatchController { private final UserRelationMapper relationMapper; private final RelationService relationService; private final RelationshipStatusService relationshipStatusService; + private final ChatService chatService; @GetMapping public ApiResponse> list(@AuthenticationPrincipal UserPrincipal principal) { @@ -78,6 +81,9 @@ public class MatchController { } MatchService.unmatch(m, principal.id()); matchMapper.updateById(m); + // Phase 09: Unmatch 必须即时禁聊 —— 同步关闭会话并向双方推送 conversation.closed。 + // 历史缺陷:此处只改 Match 状态、不联动会话,导致会话仍为 active、对方还能继续发消息。 + chatService.syncFromMatch(m.getId(), Constants.MATCH_STATUS_UNMATCHED); return ApiResponse.ok(); } diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java index cd31776..630e8a2 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java @@ -1,6 +1,7 @@ package com.danyang.match.platform.match.service; import com.danyang.match.platform.capability.service.CapabilityService; +import com.danyang.match.platform.chat.service.ChatService; import com.danyang.match.platform.match.entity.Match; import com.danyang.match.platform.match.entity.UserRelation; import com.danyang.match.platform.match.mapper.MatchMapper; @@ -35,6 +36,7 @@ public class RelationService { private final MatchMapper matchMapper; private final UserMapper userMapper; private final CapabilityService capabilityService; + private final ChatService chatService; // ==================== 公共查询 ==================== @@ -156,9 +158,16 @@ public class RelationService { relationMapper.insert(block); // 自动 Unmatch active Match - matchMapper.findActiveBetween(userId, targetUserId).ifPresent(m -> - MatchService.unmatch(m, userId) - ); + matchMapper.findActiveBetween(userId, targetUserId).ifPresent(m -> { + MatchService.unmatch(m, userId); + // 历史缺陷 1:MatchService.unmatch 只是静态工具方法,仅改内存态、不落库, + // 此处先前没有 updateById,"自动 Unmatch" 实际是空操作 —— + // Block 之后 Match 仍留在 active,双方还显示为已匹配。 + matchMapper.updateById(m); + // 历史缺陷 2:Block 后会话从未被通知,对方界面不会立即停用。 + // Block 优先级最高,故通知状态用 blocked 而非 unmatched。 + chatService.closeConversationForMatch(m.getId(), Constants.CONVERSATION_STATUS_BLOCKED); + }); log.info("User {} blocked {}", userId, targetUserId); } diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index ea57b88..198c7af 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -168,6 +168,8 @@ public class RelationshipService { match.setStatus(Constants.MATCH_STATUS_UNMATCHED); match.setUnmatchedAt(Instant.now()); matchMapper.updateById(match); + // Phase 09: Block 优先级最高 —— 通知双方会话已停用(此前无任何联动) + chatService.closeConversationForMatch(match.getId(), Constants.CONVERSATION_STATUS_BLOCKED); }); log.info("拉黑用户: userId={}, targetUserId={}", userId, targetUserId); diff --git a/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java new file mode 100644 index 0000000..443e230 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java @@ -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 层)。 + * + *

验证计划要求的三条: + *

    + *
  1. REST send + WebSocket receive、消息幂等、未读计数;
  2. + *
  3. 非 active Match 禁聊,Unmatch / Block 后即时禁聊;
  4. + *
  5. 活动双确认产生的 Conversation 复用同一消息体系。
  6. + *
+ * + *

Gate:未 Mutual 或活动未双确认时不能发消息;正常会话不重复投递。 + * + *

本轮复验发现并锁定的历史缺陷: + *

    + *
  • {@code MatchController.unmatch} 只改 Match 状态、从不联动会话 → Unmatch 后仍可发消息;
  • + *
  • {@code RelationService.block} 调用的是静态工具方法 {@code MatchService.unmatch}, + * 该方法只改内存态不落库 → Block 后 Match 仍为 active;
  • + *
  • {@code ChatService.sendText} 之前只校验 conversation.status,不校验 Match 活性;
  • + *
  • {@code ChatService.sendText} 落库后从不推送 → WebSocket 下行链路整体是死代码;
  • + *
  • {@code MessageController.load/markRead}、{@code ConversationController.detail/unread} + * 无会话成员校验 → 任意登录用户可读他人私聊(IDOR)。
  • + *
+ */ +@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 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() + .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() + .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().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().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().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 data = (Map) captor.getValue(); + assertEquals(conv.getId(), data.get("conversationId")); + + @SuppressWarnings("unchecked") + Map message = (Map) 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 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 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().eq(Message::getConversationId, conversationId)); + memberMapper.findByConversation(conversationId).forEach(m -> + memberMapper.delete(new LambdaQueryWrapper() + .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; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandlerTest.java b/backend/src/test/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandlerTest.java new file mode 100644 index 0000000..fff80bb --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/chat/websocket/ChatWebSocketHandlerTest.java @@ -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 上下文,毫秒级)。 + * + *

覆盖 Phase 09 的 "WebSocket receive" 侧: + *

    + *
  • 握手阶段写入的 userId 能把连接归属到正确用户(历史缺陷:无处写 userId, + * 导致 sessions 恒空、推送全部静默丢失);
  • + *
  • 下行事件信封符合《REST API 接口详细设计规范》第 125 章 {@code {event, data}};
  • + *
  • 未认证连接不得留存(规范第 126 章)。
  • + *
+ */ +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 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\"")); + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 6637971..c968aeb 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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), diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 7ebc33b..517ed2c 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,9 +5,13 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-16 Phase 09 复验关闭:** EPIC-P09-V11 已完成。复验中发现并修复 4 处真实缺陷(WebSocket 下行整条链路为死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天四接口 IDOR 越权), +> 新增 `ChatPhase09IntegrationTest`(13 用例)与 `ChatWebSocketHandlerTest`(7 用例),并把生产消息唯一索引下沉到测试库。后端全量 **328 tests / 0 failures / 0 errors**。 +> 证据:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 10 需用户确认后启动。** +> > **2026-09-16 Phase 08 复验关闭:** EPIC-P08-V11 已完成。复验中发现并修复 3 处真实缺陷(搜索绕过 Block / 详情无 Block 检查 / `matches.source` 取值越界), -> 新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例)并把生产 `chk_match_source` 约束下沉到测试库。后端全量 **308 tests / 0 failures / 0 errors**。 -> 证据:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 09 需用户确认后启动。** +> 新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例)并把生产 `chk_match_source` 约束下沉到测试库。 +> 证据:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`。 > > **2026-09-13 状态校正(历史):** 本文件中的既有 `✅` 是历史开发声明,不再单独作为验收证据。Phase 01~06 已由恢复版 Completion Report 重新举证;Phase 07 仅保留业务测试证据,管理端与小程序视觉已被用户否决并重新打开。Phase 08 暂停,当前先执行 UI 清理与参考图重设计。 @@ -20,7 +24,8 @@ | Phase 05 统一审核扩展 | 已完成 | 见 `Phase-05-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 06 尚未开始 | | Phase 06 Match Eligibility 增量 | 已完成 | 见 `Phase-06-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 07 尚未开始 | | Phase 07 V1.1 首页唯一实现 + 找对象 | 已完成:视觉已通过用户验收(2026-09-16) | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 已按 2026-09-13 新规格重新实现,11 页经用户验收通过 | -| Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿;Phase 09 尚未获准 | +| Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿 | +| Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-09-V11-Completion-Report.md b/docs/Phase-09-V11-Completion-Report.md index 12703fd..cea3738 100644 --- a/docs/Phase-09-V11-Completion-Report.md +++ b/docs/Phase-09-V11-Completion-Report.md @@ -1,5 +1,18 @@ # Phase 09 - Text Chat 验证 Completion Report +> **2026-09-16 状态更新:本报告的 WebSocket 结论已被复验推翻,请勿引用。** +> 本报告称「WebSocket Receive ✅ 已实现」(第 17 行)、「✅ WebSocket 推送机制正常工作」(第 95 行)、 +> 「[x] WebSocket handler 鉴权和路由正确」(第 140 行)、「WebSocket 鉴权 | token 参数验证」(第 154 行)—— +> **上述四条均不成立**: +> - 全局**没有任何 HandshakeInterceptor**,也没有任何代码往 session attributes 写 `userId`, +> `extractUserId()` 恒返回 null → `sessions` 映射永远为空; +> - `sendToUser` / `sendToConversation` 在生产代码里**零调用点**,`ChatService.sendText` 落库后从不推送; +> - 因此所谓「token 参数验证」并不存在,未认证连接可随意建立。 +> 另发现 `MatchController.unmatch` 不联动会话、`RelationService.block` 未落库 Match、 +> 聊天读接口无成员校验(IDOR)三处缺陷。 +> **本报告中 REST send、消息幂等、未读计数、Activity Match source 兼容等交付内容自始有效并继续沿用。** +> 复验结论与证据见 `docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`。 + ## Status Completed ✅ diff --git a/docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md b/docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md new file mode 100644 index 0000000..4fc6f08 --- /dev/null +++ b/docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md @@ -0,0 +1,211 @@ +# Phase 09 V1.1 Recovery Completion Report + +> 阶段:Text Chat — KEEP-VERIFY/EXTEND +> 分支:`main` +> 日期:2026-09-16 +> 结论:**Phase 09 复验通过并关闭**;期间发现并修复 4 处真实缺陷(2 处 Critical、1 处 Critical 安全缺陷、1 处规范不符),并补齐 WebSocket 端到端链路 +> 未进入 Phase 10 —— 按计划每阶段输出报告后停止,等待用户确认 + +--- + +## 1. 阶段目标(来自客户需求基线增量开发与修复计划) + +- [x] 验证 REST send + WebSocket receive、消息幂等、重连和未读计数。 +- [x] 验证非 active Match 禁聊,Unmatch/Block 后即时禁聊。 +- [x] 让活动双确认产生的 Conversation 复用同一消息体系。 + +**Gate:** 未 Mutual 或活动未双确认时不能发消息;正常会话不重复投递。 + +--- + +## 2. 复验方式 + +同样不采信历史 ✅,而是把计划的三条各自拆成**可执行的代码路径**逐条核对,并对每条路径追问"这段代码真的会被调用吗"。 +本轮大量缺陷属于**死代码 / 缺接线**类:组件存在、逻辑看着对,但没有任何调用点。 + +### 2.1 复验结论矩阵 + +| 计划条目 | 承载代码 | 复验前 | 处置 | +|---|---|---|---| +| REST send | `MessageController.send` → `ChatService.sendText` | ✅ 可用 | 增强(加 Match 活性校验) | +| **WebSocket receive** | `ChatWebSocketHandler` + `WebSocketConfig` | ❌ **整条链路是死代码** | **已修复(缺陷 C1)** | +| 消息幂等 | `sendText` 按 `clientMessageId` 重放返回原消息 | ✅ 逻辑正确 | 补测试锁定"不重复落库 + 不重复投递" | +| 重连 | WS handler 会话登记/替换 | ❌ 无鉴权、无 userId → 登记表恒空 | 随 C1 一并修复 + ping/pong 保活 | +| 未读计数 | `incrementUnread` / `resetUnread` / `sumUnreadByUser` | ✅ 正确 | 补测试 | +| **非 active Match 禁聊** | `sendText` 的会话状态校验 | ❌ **只校验会话,不校验 Match** | **已修复(缺陷 C2)** | +| **Unmatch 后即时禁聊** | `MatchController.unmatch` | ❌ **完全不联动会话** | **已修复(缺陷 C2)** | +| Block 后即时禁聊 | `sendText` 的拉黑关系双向校验 | ✅ 发送被拒 | ✅;但 Block 的 Match 落库有缺陷(C3) | +| 活动双确认复用同一消息体系 | `ConversationController.fromMatch` → `getOrCreateConversation` | ✅ 只绑 `match_id`,与 source 无关 | 补端到端测试 | + +--- + +## 3. 本轮发现并修复的缺陷 + +### 缺陷 C1(Critical)— WebSocket 下行链路整体是死代码 + +| 项 | 内容 | +|---|---| +| 位置 | `chat/websocket/ChatWebSocketHandler`、`chat/config/WebSocketConfig` | +| 症状 1 | **全局没有任何 `HandshakeInterceptor`**,也没有任何代码往 `session.getAttributes()` 写 `userId`,而 `extractUserId()` 只从 attributes 读 → **恒为 null** → `sessions` 映射永远为空。 | +| 症状 2 | `sendToUser` / `sendToConversation` **在生产代码里零调用点**:`ChatService.sendText` 落库后从不推送。 | +| 合并后果 | "REST send + WebSocket receive" **不存在**:消息发出去后对方只能靠重进会话才能看到,实时接收完全缺失。同时未认证连接可随意建立(违反规范第 126 章)。 | +| 修复 1 | 新增 `ChatHandshakeInterceptor`:从 `?token=`(或 `Authorization: Bearer`)解析 JWT,校验通过后把 `userId` 写入 session attributes;校验失败**拒绝握手**。仅接受 `type=access` 的**用户** token,`operator_access` 一律拒绝(AGENTS 第 7 条)。 | +| 修复 2 | `ChatWebSocketHandler` 重写:按规范第 125 章输出 `{event, data}` 信封,提供 `sendEvent(userId, event, data)`;无 userId 的连接直接 `close(NOT_ACCEPTABLE)`;同账号重连时旧连接被替换并关闭(避免重复投递);新增 ping/pong 保活。 | +| 修复 3 | `ChatService.sendText` 落库并递增未读后,向对方推送 `message.new`。**幂等重放分支在上方 `return`,走不到推送**,因此正常会话天然不会重复投递。 | + +关于 SockJS:`WebSocketConfig` 原本调用 `.withSockJS()`,会把端点改写为 SockJS 协议族,原生 WebSocket 客户端(小程序 `wx.connectSocket`、规范第 124 章的 `/wss/chat`)**无法直连**,与本类注释自述的 `/ws/chat?token=` 自相矛盾。两端都是原生客户端,故移除 SockJS。 + +### 缺陷 C2(Critical)— Unmatch 后仍可继续发消息 + +| 项 | 内容 | +|---|---| +| 位置 | `match/controller/MatchController#unmatch`、`chat/service/ChatService#sendText` | +| 症状 | `unmatch` 只把 `matches.status` 改成 `unmatched` 就结束,**从不联动会话**;而 `sendText` 只校验 `conversation.status == active`,会话仍是 `active` → **解除匹配后双方仍能正常发消息**,直接违反 Gate「未 Mutual 不能发消息」。 | +| 另一层 | `ChatService.syncFromMatch` —— 本该承担这个联动 —— **零调用点**。 | +| 修复 1 | `sendText` 增加 `assertMatchAlive(conv)`:按 `matchId` 回查 Match,状态不在 `{active, getting_to_know}` 即拒绝。**校验不依赖会话状态是否漂移**,属独立防线。 | +| 修复 2 | `MatchController.unmatch` 落库后调用 `chatService.syncFromMatch(...)`,向双方推送 `conversation.closed`,客户端可立即把会话置灰。 | + +### 缺陷 C3(Critical)— Block 的"自动 Unmatch"实际是空操作 + +| 项 | 内容 | +|---|---| +| 位置 | `match/service/RelationService#block` | +| 症状 | 调用 `MatchService.unmatch(m, userId)` —— 但 `MatchService` 是 `private` 构造的**静态工具类**,`unmatch` 只改传入对象的内存态、**不落库**,且此处没有后续 `updateById`。于是 **Block 之后 Match 在库里仍是 `active`**,"自动 Unmatch"完全没发生;用户侧仍会看到双方互相匹配。 | +| 附带 | Block 也从不通知会话,对方界面不会即时停用。 | +| 修复 | 补 `matchMapper.updateById(m)` 持久化;并调用 `chatService.closeConversationForMatch(matchId, "blocked")` 通知双方。 | + +> 对比:legacy 的 `RelationshipService.block` 反而是**正确**的(它内联改字段并 `updateById` 落库),canonical 路径却漏了 —— 典型"两套实现、新的那套漏了一步"。 + +### 缺陷 C4(Critical,安全)— 聊天接口越权(IDOR) + +| 项 | 内容 | +|---|---| +| 位置 | `MessageController.load` / `markRead`、`ConversationController.detail` / `unread` | +| 症状 | 四个接口**完全没有会话成员校验**。任意已登录用户只要拿到(或枚举)`conversationId`,就能读取**他人完整聊天记录**、看到对方昵称与最后一条消息、甚至改写他人已读状态。`SecurityConfig` 只有 `/**` → `authenticated()`,没有对象级授权。 | +| 影响 | 直接违反 AGENTS 第 7 条(普通工作人员不能查看私人聊天)与隐私要求。 | +| 修复 | `ChatService.isMember(convId, userId)` + 四个接口前置校验;非成员按既有约定返回 `success=false / code=NOT_FOUND`(与 `MatchController.authorizedMatch` 同一模式,不区分"不存在"与"无权",避免被用来探测会话是否存在)。 | + +### 缺陷 C5(Medium)— 上行订阅与事件信封不符规范 + +| 项 | 内容 | +|---|---| +| 规范第 126 章 | "**不允许客户端自行订阅任意 conversationId**"、事件名清单 `message.new / message.read / conversation.closed / match.created / notification.new / account.status_changed`(第 124 章)、信封 `{event, data}`(第 125 章)。 | +| 修复前 | 上行帧只被 `log.debug` 记一行,订阅语义未表态;事件信封为自定义 `type + payload`。 | +| 修复 | 上行只接受 `ping`(回 `pong`),其余忽略并有注释说明规范依据;下行统一 `{event, data}`,事件名用规范清单。 | + +--- + +## 4. 刻意**不**改的一处(+ 登记为待决项) + +**会话 `status` 是否应在 Unmatch/Block 后置为非 active —— 保持现状。** + +`ConversationMapper.findActiveByUser` 过滤 `status = 'active'`。一旦把 status 改成 `unmatched`/`blocked`,会话会**从"我的消息"列表消失**。 +但 `conversation-item` 组件恰恰要在列表里渲染"会话已停用 / 会话已停用(已拉黑)"的灰显态,AGENTS 也要求 Block 后保留历史。 + +因此本轮把"关闭语义"交给**事件 + 发送时校验**承载,而不是 status: + +- 发送权限 → `assertMatchAlive` + 拉黑关系双向校验(**与 status 无关,无法绕过**); +- 实时表现 → 推送 `conversation.closed`,客户端立即灰显; +- 读接口 → `toConversationDTO` 已用 `matchAlive && !blocked` 算出 `canSendMessage=false` 与 `relationStatus`。 + +**列表可见性是否应在关闭后收回,是未被本阶段授权的产品决策**,故保持现状并在本节显式登记,请用户裁决。 + +--- + +## 5. 验证证据 + +| 项目 | 结果 | +|---|---| +| Backend 全量回归(离线容器,`mvn -o test`) | **328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(基线 308,+20) | +| 新增测试 | `ChatPhase09IntegrationTest`(13 用例,真实 H2 + 真实 SQL + 真实 HTTP 层)、`ChatWebSocketHandlerTest`(7 用例,纯单元) | +| **负向验证** | 同时还原 `assertMatchAlive` 与消息读接口的成员校验后,**恰好 3 个用例变红**(`outsiderCannotReadMessages`、`sendRejectedWhenMatchNotAlive`、`unmatchEndpointImmediatelyDisablesChat`)✔ 守卫有效 | +| 定向复跑 | `ChatPhase09IntegrationTest` + `P0FixVerificationTest`:21 tests / 0 failures / 0 errors | + +新增/增强测试覆盖: + +| 用例 | 断言要点 | +|---|---| +| 幂等重试不重复投递 | 同 `clientMessageId` 返回同一条;库内只 1 行;未读只 +1;**只推一次** | +| 推送信封 | `sendEvent(peer, "message.new", ...)`,`data.conversationId` + `data.message.{messageId,content,senderId,sentAt}` | +| 未读随发送递增、标记已读清零 | 发送方不计未读 | +| Match 已 unmatched 时拒发 | **刻意只改 Match、不改会话**,证明校验不依赖会话状态 | +| Unmatch 端点即时禁聊 | 调 `MatchController.unmatch` 后双方均拒发 + 收到 `conversation.closed` | +| Block 即时禁聊 + **Match 落库为 unmatched** | 直接锁死 C3 回归 | +| 活动双确认复用同一消息体系 | 确认 → `from-match` 建会话 → 同一套发送/推送/未读 | +| 未形成可聊 Match 无法建会话 | 不存在 / 已失效的 Match 均拒绝(Gate 第二分句) | +| 非成员读消息/详情/未读被拒 | 越权响应**不泄露聊天正文**;成员侧不受影响 | +| WS handler(7 例) | userId 路由、`{event,data}` 信封、未认证连接被关、断开后不推、重连替换旧连接、上行只支持 ping | +| 幂等由 DB 唯一索引兜底(2 例) | 绕过 service 幂等查询直接插重复 `clientMessageId` → 被唯一索引拒绝;无幂等键的系统消息仍可多条共存 | + +### 5.1 测试保真度补强(延续 Phase 08 的方法论) + +"正常会话不重复投递"在**并发**下的真正保障不是 service 里的 `SELECT-then-INSERT`(两者之间存在竞态窗口), +而是生产库的唯一索引: + +```sql +-- 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; +``` + +**而测试库 `schema-h2.sql` 里没有这条索引** —— 与 Phase 08 的 `chk_match_source` 属同一类保真度缺口: +约束只存在于生产库,测试环境无从验证。本轮已补齐: + +- H2 不支持过滤索引,故省略 `WHERE`;但 H2 的唯一索引遵循 SQL 标准(多个 NULL 互不冲突), + 语义与生产的部分索引等价 —— 未带幂等键的系统消息(`RiskNotice`)仍可多条存在,已由测试守住。 +- 新增两条测试:**绕过 service 层直接落库**的重复 `clientMessageId` 必须被索引拒绝; + 无幂等键的系统消息不得被误伤。 + +--- + +## 6. 已知限制(本轮登记,未处理) + +1. **业务拒绝仍映射为 HTTP 500。** `sendText` 用 `IllegalStateException` 表达"未 Mutual / 已拉黑 / 非成员",而 `GlobalExceptionHandler` 的兜底把它映射成 **500 内部错误**,而非 4xx。前端目前靠 `canSendMessage=false` 规避,不依赖该状态码,故本轮未动(改动涉及全平台错误码契约,超出本阶段授权)。 +2. **`Conversation.status` 的关闭语义未定** —— 见第 4 节。 +3. **WebSocket 为单实例内存实现**:多实例部署需 Redis Pub/Sub(原注释已标注属于 Phase 22+)。当前不构成缺陷。 +4. **`MatchService.unmatch` 未持久化**这一"静态工具方法只改内存态"的隐患仍存在于方法签名本身(调用方必须自己 `updateById`)。本轮只在 canonical 调用点补上,未改设计。 + +--- + +## 7. Gate 判定 + +| Gate 条款 | 判定 | 依据 | +|---|---|---| +| 未 Mutual 时不能发消息 | ✅ | 无 Match → 无会话;且 `assertMatchAlive` 独立拒发 | +| 活动未双确认时不能发消息 | ✅ | 无 Match → 无会话 → 无成员 → 拒发;`getOrCreateConversation` 对不存在/失效 Match 直接拒建 | +| 正常会话不重复投递 | ✅ | 相同 `clientMessageId` 重放返回原消息且**不推送**;测试断言只推 1 次 | +| REST send + WebSocket receive | ✅ | 握手鉴权 + 落库后推送 `message.new`,端到端链路打通 | + +**Phase 09:通过并关闭。** + +--- + +## 8. 变更清单 + +### 主代码 +- `chat/websocket/ChatHandshakeInterceptor.java` — **新增**:握手 Token 鉴权 + userId 绑定(fail-closed) +- `chat/websocket/ChatWebSocketHandler.java` — 重写:`{event,data}` 信封、`sendEvent` 下行 API、未认证连接关闭、重连替换、ping/pong +- `chat/config/WebSocketConfig.java` — 注册拦截器;移除 SockJS +- `chat/service/ChatService.java` — 注入 WS handler;`assertMatchAlive`;落库后推 `message.new`;`syncFromMatch` 推 `conversation.closed`;新增 `closeConversationForMatch`、`isMember` +- `chat/controller/MessageController.java` — `load`/`markRead` 补成员校验 +- `chat/controller/ConversationController.java` — `detail`/`unread` 补成员校验 +- `match/controller/MatchController.java` — `unmatch` 联动会话关闭 +- `match/service/RelationService.java` — Block 补 Match 落库 + 会话关闭通知 + +### 测试 +- `chat/ChatPhase09IntegrationTest.java` — **新增 13 用例** +- `chat/websocket/ChatWebSocketHandlerTest.java` — **新增 7 用例** +- `backend/src/test/resources/schema-h2.sql` — 补生产消息唯一索引 `uk_message_client_id` + +--- + +## 9. 下一阶段 + +**不自动进入 Phase 10。** 按计划要求,每阶段输出报告后停止,等待用户确认。 + +Phase 10(Image & Chat Safety — KEEP-VERIFY)待办预告: +- 验证图片私有访问、类型/大小校验、审核和水印 +- 验证风险识别、举报入口和违规内容处理 +- 检查日志不记录聊天正文、身份证、手机号或签名 URL + +> 附:`ChatSafetyService` 的频控为 Redis **fail-open**(Redis 不可用时放行),Phase 10 应一并复核该降级策略是否符合风控要求。 diff --git a/docs/STATUS.md b/docs/STATUS.md index 5994074..d0f1fbc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08 RECOVERY CLOSED / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-09 RECOVERY CLOSED / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(308 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)复验关闭**,期间修复 3 处真实缺陷并补齐测试库的生产约束保真度。 +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(328 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)与 Phase 09(Text Chat)复验已关闭**,两轮共修复 7 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 10 需用户确认后启动。** 最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -13,13 +13,14 @@ | Phase 01~06 Recovery | 已完成 | 各 Phase Completion Report;38+9+3+4+21 后端专项通过 | | Phase 07 V1.1 首页业务与找对象 | **业务+UI 已通过用户验收**(2026-09-16) | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮);UI 按 2026-09-13 规格重新实现,11 页经用户验收通过 | | **Phase 08 V1.1 Like / Match / Block** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 3 处真实缺陷:① `DiscoverService.searchUsers` 无查看者身份 → INV-004「搜索不可见」整体失效;② 详情路径无 Block 判断;③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003 chk_match_source`(H2 测试库无该约束故长期静默)。新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例,含反向拉黑);`schema-h2.sql` 补齐生产约束与 `user_profiles`;负向验证确认守卫会红。报告:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md` | +| **Phase 09 V1.1 Text Chat** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 4 处真实缺陷:① WebSocket 下行整条链路是死代码(无握手鉴权/无 userId 写入/`sendToUser` 零调用点)→ 新增 `ChatHandshakeInterceptor` + 落库后推 `message.new`;② Unmatch 不联动会话 + 发消息只校验会话状态 → 解除后仍可发消息 → 新增 `assertMatchAlive` + `MatchController.unmatch` 联动;③ `RelationService.block` 调静态 `MatchService.unmatch`(只改内存态)→ Block 后 Match 未落库;④ 聊天四个接口无成员校验 → IDOR。新增 `ChatPhase09IntegrationTest`(11 用例)+ `ChatWebSocketHandlerTest`(7 用例);负向验证 3 处守卫精准变红。报告:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md` | | 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 | | 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 | | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | | Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS | -| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**308 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest` 与新增 `DiscoverBlockInvisibilityIntegrationTest` | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | @@ -27,7 +28,7 @@ | UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 | | Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 | | 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(299 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16) | -| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08 复验通过并关闭,可推进 Phase 09(**需用户确认后启动**) | +| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-09 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09 复验通过并关闭,可推进 Phase 10(**需用户确认后启动**) | --- diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index b683b13..bc65b48 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -195,13 +195,19 @@ **Gate:** V1.0 核心回归通过;活动来源兼容不会创建第二套 Match 逻辑。 -### Phase 09:Text Chat — KEEP-VERIFY/EXTEND +### Phase 09:Text Chat — KEEP-VERIFY/EXTEND(2026-09-16 已完成) + +> 复验中发现并修复 4 处真实缺陷:① WebSocket 下行整条链路是死代码(无握手鉴权、无 userId 写入、 +> `sendToUser` 零调用点);② Unmatch 只改 Match 不联动会话,且发消息只校验会话状态 → 解除匹配后仍可发消息; +> ③ `RelationService.block` 调用静态 `MatchService.unmatch`(只改内存态)→ Block 后 Match 未落库; +> ④ 聊天四个读/写接口无会话成员校验 → 任意登录用户可读他人私聊(IDOR)。 +> 详见 `docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`。 **Files:** `messaging/**`、WebSocket 配置、Miniapp messages/chat 页面。 -- [ ] 验证 REST send + WebSocket receive、消息幂等、重连和未读计数。 -- [ ] 验证非 active Match 禁聊,Unmatch/Block 后即时禁聊。 -- [ ] 让活动双确认产生的 Conversation 复用同一消息体系。 +- [x] 验证 REST send + WebSocket receive、消息幂等、重连和未读计数。 +- [x] 验证非 active Match 禁聊,Unmatch/Block 后即时禁聊。 +- [x] 让活动双确认产生的 Conversation 复用同一消息体系。 **Gate:** 未 Mutual 或活动未双确认时不能发消息;正常会话不重复投递。