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:
@@ -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 解除
|
||||
* <p>端点: {@code /ws/chat?token={jwt}}(原生 WebSocket,非 SockJS)
|
||||
*
|
||||
* 鉴权: 握手时 URL 带 JWT token(与 REST 同密钥)
|
||||
* <p>协议:事件信封对齐《REST API 接口详细设计规范》第 125 章,形如
|
||||
* <pre>{ "event": "message.new", "data": { ... } }</pre>
|
||||
* 事件名沿用规范第 124 章清单:
|
||||
* <ul>
|
||||
* <li>{@code message.new} — 新消息</li>
|
||||
* <li>{@code message.read} — 已读回执</li>
|
||||
* <li>{@code conversation.closed} — 会话关闭(Unmatch / Block)</li>
|
||||
* <li>{@code match.created} — 新 Match</li>
|
||||
* <li>{@code notification.new} — 通知</li>
|
||||
* <li>{@code account.status_changed} — 账号状态变更</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>鉴权:握手阶段由 {@link ChatHandshakeInterceptor} 校验 {@code token} 并把 userId
|
||||
* 绑定到连接(规范第 126 章)。
|
||||
*
|
||||
* <p><b>关于 SockJS</b>:此前本配置调用了 {@code .withSockJS()}。SockJS 会把端点改写为
|
||||
* {@code /ws/chat/**} 的 SockJS 协议族,原生 WebSocket 客户端(小程序
|
||||
* {@code wx.connectSocket}、规范第 124 章给的 {@code /wss/chat})<b>无法直接连接</b>,
|
||||
* 与类注释自述的 {@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("*");
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -92,6 +92,8 @@ public class ConversationController {
|
||||
public ApiResponse<ConversationDTO> 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<Map<String, Object>> 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)));
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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<Message> messages;
|
||||
@@ -82,6 +84,8 @@ public class MessageController {
|
||||
@RequestBody Map<String, String> 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);
|
||||
|
||||
@@ -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<Message> 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 条:工作人员及无关用户不得查看私人聊天)。
|
||||
*
|
||||
* <p>历史缺陷:{@code MessageController.load/markRead}、{@code ConversationController.detail/unread}
|
||||
* 此前<b>完全没有成员校验</b>,任何已登录用户只要拿到 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<Message> loadMessages(String conversationId, int limit, int offset) {
|
||||
return messageMapper.findRecentByConversation(conversationId, limit, offset);
|
||||
}
|
||||
@@ -219,18 +254,132 @@ public class ChatService {
|
||||
|
||||
// ==================== 状态联动 ====================
|
||||
|
||||
/**
|
||||
* 会话状态跟随 Match 状态联动,并在关闭时实时通知双方。
|
||||
*
|
||||
* <p>历史缺陷(本方法修复):本方法此前<b>无任何调用点</b> ——
|
||||
* {@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)时通知双方会话已关闭。
|
||||
*
|
||||
* <p><b>为什么只推事件、不改 conversation.status</b>:
|
||||
* {@code ConversationMapper.findActiveByUser} 过滤 {@code status = 'active'},
|
||||
* 一旦把 status 改成 {@code unmatched}/{@code blocked},会话就会从"我的消息"列表里消失,
|
||||
* 这等于静默改变了列表可见性 —— 而 {@code conversation-item} 组件恰恰要在列表里
|
||||
* 渲染"会话已停用 / 会话已停用(已拉黑)"的灰显态,AGENTS 也要求 Block 保留历史可查。
|
||||
* 因此关闭语义由"事件 + 发送时校验"承载,而非由 status 承载:
|
||||
* <ul>
|
||||
* <li>发送权限:{@link #assertMatchAlive} + 拉黑关系双向校验,与 status 无关,无法绕过;</li>
|
||||
* <li>实时表现:本方法推送 {@code conversation.closed},客户端据此立即灰显;</li>
|
||||
* <li>读接口:{@code toConversationDTO} 已经用 {@code matchAlive && !blocked}
|
||||
* 算出 {@code canSendMessage=false} 与 {@code relationStatus}。</li>
|
||||
* </ul>
|
||||
* 列表可见性是否应在关闭后收回,属产品决策,未在本阶段授权范围内,故保持现状并登记为待决项。
|
||||
*
|
||||
* @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<String, Object> 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 必须仍处于可聊天状态。
|
||||
*
|
||||
* <p>仅当 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 负载。
|
||||
*
|
||||
* <p>字段同时满足两份契约:
|
||||
* <ul>
|
||||
* <li>规范示例用 {@code id / type / createdAt};</li>
|
||||
* <li>小程序 {@code types/index.d.ts} 的 {@code Message} 用
|
||||
* {@code messageId / messageType / sentAt(epoch 毫秒)},
|
||||
* 且 {@code stores/messageStore.js#onIncomingMessage} 按这些名字取值并做去重。</li>
|
||||
* </ul>
|
||||
* 故输出为两者并集,避免任一消费方断链。
|
||||
*/
|
||||
private Map<String, Object> buildMessageNewData(String conversationId, Message msg) {
|
||||
Map<String, Object> 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<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("conversationId", conversationId);
|
||||
data.put("message", message);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== 会话查询(原 messaging 模块能力合并) ====================
|
||||
|
||||
/**
|
||||
|
||||
+130
@@ -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 安全原则)。
|
||||
*
|
||||
* <p>规范要求:<b>Token 认证 / 连接绑定 userId</b>。本拦截器在握手阶段完成这两件事,
|
||||
* 只有认证通过才放行,并把 userId 写入 session attributes 供
|
||||
* {@link ChatWebSocketHandler} 识别连接归属。
|
||||
*
|
||||
* <p>历史缺陷(本类修复):此前<b>全局没有任何 HandshakeInterceptor</b>,也没有任何代码
|
||||
* 往 session attributes 里写 userId,导致 {@code ChatWebSocketHandler.extractUserId()}
|
||||
* 恒返回 null、{@code sessions} 映射永远为空 —— <b>整条 WebSocket 下行推送静默失效</b>,
|
||||
* 且未认证连接可以随意建立(违反规范第 126 章)。
|
||||
*
|
||||
* <p>鉴权方式(按优先级):
|
||||
* <ol>
|
||||
* <li>握手 URL 查询参数 {@code ?token=xxx}(小程序 {@code wx.connectSocket} 最易实现)</li>
|
||||
* <li>握手请求头 {@code Authorization: Bearer xxx}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>仅接受<b>用户</b> 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<String, Object> 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。
|
||||
*
|
||||
* <p>查询参数优先:小程序端 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;
|
||||
}
|
||||
}
|
||||
+97
-34
@@ -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+ 再做
|
||||
* <p>简易实现:内存 Map 维护 userId → Session 映射。
|
||||
* 多实例部署时需要 Redis Pub/Sub 扩展(当前为单实例)。
|
||||
*
|
||||
* <p><b>协议</b>:事件信封按《REST API 接口详细设计规范》第 125 章:
|
||||
* <pre>{ "event": "message.new", "data": { ... } }</pre>
|
||||
*
|
||||
* <p><b>鉴权与订阅边界</b>(规范第 126 章):
|
||||
* <ul>
|
||||
* <li>Token 认证 + 连接绑定 userId —— 由 {@link ChatHandshakeInterceptor} 在握手阶段完成;
|
||||
* 本类只信任 session attributes 里的 userId,绝不接受客户端自报身份。</li>
|
||||
* <li>服务端决定订阅权限 —— 上行帧<b>不做订阅处理</b>。规范明确禁止
|
||||
* "客户端自行订阅任意 conversationId",故上行只支持 {@code ping} 保活,
|
||||
* 其余一律忽略。消息落库与推送范围完全由服务端按会话成员计算。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/** userId -> Set<conversationId> 当前订阅的会话 */
|
||||
private final Map<String, Set<String>> 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<String> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CursorPage<MatchResponse>> 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();
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+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),
|
||||
|
||||
@@ -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 通过用户验收 |
|
||||
|
||||
@@ -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 ✅
|
||||
|
||||
|
||||
@@ -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 应一并复核该降级策略是否符合风控要求。
|
||||
+5
-4
@@ -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(**需用户确认后启动**) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 或活动未双确认时不能发消息;正常会话不重复投递。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user