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);
|
||||
|
||||
Reference in New Issue
Block a user