forked from baskbuddy/zhangshun
45 changed files with 927 additions and 36 deletions
@ -0,0 +1,18 @@ |
|||
# 实时聊天转发片段:合并到现有 HTTPS server 中,不要覆盖整个 Nginx 配置。 |
|||
# 部署前核对真实后端地址、端口以及 /api/v1 context-path。 |
|||
# 此示例仅用于单后端实例;多实例需要共享消息代理,不能仅添加负载均衡。 |
|||
# 前端使用 wss://你的业务域名/api/v1/ws;Token 放在 STOMP CONNECT 头,不放 URL。 |
|||
location = /api/v1/ws { |
|||
proxy_pass http://127.0.0.1:8080; |
|||
proxy_http_version 1.1; |
|||
proxy_set_header Upgrade $http_upgrade; |
|||
proxy_set_header Connection "upgrade"; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_read_timeout 75s; |
|||
proxy_send_timeout 75s; |
|||
proxy_buffering off; |
|||
} |
|||
# 握手限流目前按直接连接来源计数;经过反向代理时来源可能都是网关。 |
|||
# 大规模部署需在网关增加按可信真实 IP 的限流,并核对后端限流配置。 |
|||
@ -0,0 +1,10 @@ |
|||
package com.mydreamjob.common; |
|||
|
|||
/** 对外品牌统一入口;不改变包名、数据库、接口路径及部署标识。 */ |
|||
public final class ProductBrand { |
|||
public static final String DISPLAY_NAME = "轻松聘"; |
|||
public static final String API_TITLE = DISPLAY_NAME + " API"; |
|||
public static final String API_DESCRIPTION = DISPLAY_NAME + " - 大学生求职服务平台 API"; |
|||
|
|||
private ProductBrand() { } |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.*; |
|||
import java.time.LocalDateTime; |
|||
|
|||
@Entity |
|||
@Table(name = "chat_events") |
|||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor |
|||
public class ChatEvent { |
|||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
@Column(name = "conversation_id", nullable = false) private Long conversationId; |
|||
@Column(name = "recipient_user_id", nullable = false) private Long recipientUserId; |
|||
@Column(name = "actor_user_id", nullable = false) private Long actorUserId; |
|||
@Column(name = "message_id", nullable = false) private Long messageId; |
|||
@Column(name = "event_type", nullable = false, length = 32) private String eventType; |
|||
@Column(name = "created_at", nullable = false) private LocalDateTime createdAt; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.domain.Pageable; |
|||
import java.util.List; |
|||
|
|||
public interface ChatEventRepository extends JpaRepository<ChatEvent, Long> { |
|||
List<ChatEvent> findByConversationIdAndRecipientUserIdAndIdGreaterThanOrderByIdAsc( |
|||
Long conversationId, Long recipientUserId, Long cursor, Pageable pageable); |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import com.mydreamjob.messaging.*; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.context.ApplicationEventPublisher; |
|||
import org.springframework.messaging.simp.SimpMessagingTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.transaction.event.TransactionalEventListener; |
|||
import org.springframework.transaction.event.TransactionPhase; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
import java.util.*; |
|||
|
|||
/** 事件和业务写入在同一事务;只在提交后推送,发送失败由持久化游标恢复。 */ |
|||
@Service @RequiredArgsConstructor @Slf4j |
|||
public class ChatEventService { |
|||
private final ChatEventRepository events; |
|||
private final ConversationMemberRepository members; |
|||
private final ConversationRepository conversations; |
|||
private final MessageRepository messages; |
|||
private final MessageReadRepository reads; |
|||
private final MessageViewMapper mapper; |
|||
private final ApplicationEventPublisher publisher; |
|||
private final SimpMessagingTemplate socket; |
|||
private final Clock clock; |
|||
|
|||
public record CommittedEvent(Long eventId) {} |
|||
|
|||
@Transactional(propagation = Propagation.MANDATORY) |
|||
public void record(String type, Long conversationId, Long actorId, Long messageId) { |
|||
for (var member : members.findByConversationId(conversationId)) { |
|||
var event = events.save(ChatEvent.builder().conversationId(conversationId) |
|||
.recipientUserId(member.getUserId()).actorUserId(actorId).messageId(messageId) |
|||
.eventType(type).createdAt(LocalDateTime.now(clock)).build()); |
|||
publisher.publishEvent(new CommittedEvent(event.getId())); |
|||
} |
|||
} |
|||
|
|||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) |
|||
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) |
|||
public void push(CommittedEvent committed) { |
|||
try { |
|||
events.findById(committed.eventId()).ifPresent(event -> { |
|||
var view = view(event); |
|||
if (view != null) socket.convertAndSendToUser(event.getRecipientUserId().toString(), |
|||
"/queue/chat-events", view); |
|||
}); |
|||
} catch (Exception ignored) { |
|||
// 不把已提交的发送伪报为失败,不记录正文或身份凭据。
|
|||
log.warn("Chat event push failed; cursor replay is available"); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 返回 items / nextCursor / hasMore;初始 after=0,size 最大值由入口限制为 100。 |
|||
* 客户端仅在补拉页成功合并之后持久化 nextCursor,并持续翻页直到 hasMore=false。 |
|||
* 实时帧可能晚到或乱序,不得用推送 eventId 直接推进补拉游标。 |
|||
* payload 是消息当前状态(并非历史正文快照),按 message.id + version 合并。 |
|||
* MESSAGE_READ 的 actorScope 表示谁执行已读;payload.isRead 仍表示当前用户已读。 |
|||
*/ |
|||
@Transactional(readOnly = true) |
|||
public Map<String, Object> replay(Long conversationId, Long userId, long after, int size) { |
|||
var rows = events.findByConversationIdAndRecipientUserIdAndIdGreaterThanOrderByIdAsc( |
|||
conversationId, userId, after, PageRequest.of(0, size + 1)); |
|||
boolean hasMore = rows.size() > size; |
|||
var page = rows.stream().limit(size).toList(); |
|||
var items = page.stream().map(this::view).filter(Objects::nonNull).toList(); |
|||
long next = page.isEmpty() ? after : page.get(page.size() - 1).getId(); |
|||
return Map.of("items", items, "nextCursor", next, "hasMore", hasMore); |
|||
} |
|||
|
|||
private Map<String, Object> view(ChatEvent event) { |
|||
// 防止成员移除之后继续读取旧事件。
|
|||
if (!members.existsByConversationIdAndUserId(event.getConversationId(), event.getRecipientUserId())) return null; |
|||
var message = messages.findById(event.getMessageId()).orElse(null); |
|||
var conversation = conversations.findById(event.getConversationId()).orElse(null); |
|||
if (message == null || conversation == null) return null; |
|||
var response = mapper.toResponse(message, event.getRecipientUserId(), |
|||
"TALENT_INVITATION".equals(conversation.getType())); |
|||
response.setIsRead(reads.existsByMessageIdAndReaderUserId(message.getId(), event.getRecipientUserId())); |
|||
return Map.of("eventId", event.getId(), "type", event.getEventType(), |
|||
"conversationId", event.getConversationId(), "occurredAt", event.getCreatedAt(), |
|||
"actorScope", Objects.equals(event.getActorUserId(), event.getRecipientUserId()) |
|||
? "CURRENT_USER" : "OTHER_PARTICIPANT", "payload", response); |
|||
} |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.messaging.*; |
|||
import org.springframework.messaging.support.*; |
|||
import org.springframework.messaging.simp.stomp.*; |
|||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
|||
import org.springframework.stereotype.Component; |
|||
import java.time.Clock; |
|||
import java.util.*; |
|||
|
|||
/** 只允许 CONNECT 鉴权和自己的固定队列;不允许 STOMP SEND 绕过 HTTP 服务层。 */ |
|||
@Component @RequiredArgsConstructor @lombok.extern.slf4j.Slf4j |
|||
public class ChatSocketAuth implements ChannelInterceptor { |
|||
static final String CLAIMS = "chat.accessClaims"; |
|||
private final JwtTokenProvider tokens; |
|||
private final AuthSessionService sessions; |
|||
private final UserRepository users; |
|||
private final com.mydreamjob.company.CompanyRepository companies; |
|||
private final Clock clock; |
|||
private final java.util.concurrent.ConcurrentHashMap<String, AccessTokenClaims> connected = |
|||
new java.util.concurrent.ConcurrentHashMap<>(); |
|||
|
|||
@org.springframework.context.event.EventListener |
|||
public void disconnected(org.springframework.web.socket.messaging.SessionDisconnectEvent event) { |
|||
connected.remove(event.getSessionId()); |
|||
} |
|||
|
|||
boolean active(AccessTokenClaims claims) { |
|||
if (claims == null || !clock.instant().isBefore(claims.expiresAt())) return false; |
|||
if (!sessions.isSessionActive(claims.userId(), claims.sessionId(), clock.instant())) return false; |
|||
return users.findByIdWithRoles(claims.userId()).map(u -> { |
|||
if (u.getRole() == User.Role.UNASSIGNED) return false; |
|||
if (u.getRoles() != null && u.getRoles().contains(User.Role.COMPANY)) { |
|||
return companies.findByOwnerUserId(u.getId()) |
|||
.map(com.mydreamjob.company.VerifiedCompanyGuard::isVerified).orElse(false); |
|||
} |
|||
return true; |
|||
}).orElse(false); |
|||
} |
|||
|
|||
@Override |
|||
public Message<?> preSend(Message<?> message, MessageChannel channel) { |
|||
var accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); |
|||
if (accessor == null) throw denied(); |
|||
var attrs = accessor.getSessionAttributes(); |
|||
if (attrs == null) throw denied(); |
|||
if (accessor.getCommand() == StompCommand.DISCONNECT) return message; |
|||
try { |
|||
if (accessor.getCommand() == StompCommand.CONNECT) { |
|||
if (attrs.containsKey(CLAIMS)) throw denied(); |
|||
var auth = accessor.getNativeHeader("Authorization"); |
|||
if (auth == null || auth.size() != 1 || !auth.get(0).startsWith("Bearer ") |
|||
|| auth.get(0).length() > 8192) throw denied(); |
|||
var claims = tokens.parseAndValidateAccessToken(auth.get(0).substring(7), clock.instant()); |
|||
if (!active(claims)) throw denied(); |
|||
if (accessor.getSessionId() == null) throw denied(); |
|||
synchronized (connected) { |
|||
if (connected.values().stream().filter(c -> c.userId().equals(claims.userId())).count() >= 5) |
|||
throw denied(); |
|||
connected.put(accessor.getSessionId(), claims); |
|||
} |
|||
accessor.setUser(new UsernamePasswordAuthenticationToken(claims.userId(), null, List.of())); |
|||
attrs.put(CLAIMS, claims); |
|||
// 原始 Token 不进入会话属性或后续帧。
|
|||
accessor.removeNativeHeader("Authorization"); |
|||
} else { |
|||
if (!active((AccessTokenClaims) attrs.get(CLAIMS))) throw denied(); |
|||
if (accessor.getCommand() == StompCommand.SUBSCRIBE |
|||
&& !"/user/queue/chat-events".equals(accessor.getDestination())) throw denied(); |
|||
if (accessor.getCommand() != StompCommand.SUBSCRIBE |
|||
&& accessor.getCommand() != StompCommand.UNSUBSCRIBE |
|||
&& accessor.getCommand() != null) throw denied(); |
|||
} |
|||
synchronized (attrs) { |
|||
long now = clock.millis(); |
|||
long start = (long) attrs.getOrDefault("chat.rateStart", now); |
|||
int count = (int) attrs.getOrDefault("chat.rateCount", 0); |
|||
if (now - start >= 60000) { start = now; count = 0; } |
|||
if (++count > 120) throw denied(); |
|||
attrs.put("chat.rateStart", start); |
|||
attrs.put("chat.rateCount", count); |
|||
} |
|||
return message; |
|||
} catch (Exception ignored) { |
|||
throw denied(); |
|||
} |
|||
} |
|||
|
|||
private MessageDeliveryException denied() { |
|||
return new MessageDeliveryException("Chat connection rejected"); |
|||
} |
|||
|
|||
/** 推送时再次检查,防止过期、停用或登出后仍然接收消息。 */ |
|||
public ChannelInterceptor outbound() { |
|||
return new ChannelInterceptor() { |
|||
@Override public Message<?> preSend(Message<?> message, MessageChannel channel) { |
|||
var accessor = MessageHeaderAccessor.getAccessor(message, |
|||
org.springframework.messaging.simp.SimpMessageHeaderAccessor.class); |
|||
if (accessor == null || accessor.getMessageType() != org.springframework.messaging.simp.SimpMessageType.MESSAGE) |
|||
return message; |
|||
try { |
|||
if (!active(connected.get(accessor.getSessionId()))) { |
|||
log.warn("Chat outbound authorization rejected"); return null; |
|||
} |
|||
return message; |
|||
} catch (Exception ignored) { return null; } |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.context.annotation.*; |
|||
import org.springframework.messaging.simp.config.*; |
|||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; |
|||
import org.springframework.web.socket.config.annotation.*; |
|||
|
|||
/** 当前使用单实例内置 broker;多实例部署必须改为共享 broker,不能仅添加负载均衡。 */ |
|||
@Configuration @EnableWebSocketMessageBroker @RequiredArgsConstructor |
|||
public class ChatSocketConfig implements WebSocketMessageBrokerConfigurer { |
|||
private final ChatSocketAuth auth; |
|||
private final ChatSocketConnections connections; |
|||
private final org.springframework.beans.factory.ObjectProvider<org.springframework.messaging.simp.SimpMessagingTemplate> socket; |
|||
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") |
|||
private String origins; |
|||
|
|||
@Bean public ThreadPoolTaskScheduler chatHeartbeatScheduler() { |
|||
var scheduler = new ThreadPoolTaskScheduler(); |
|||
scheduler.setPoolSize(2); |
|||
scheduler.setThreadNamePrefix("chat-heartbeat-"); |
|||
return scheduler; |
|||
} |
|||
@Override public void registerStompEndpoints(StompEndpointRegistry registry) { |
|||
registry.addEndpoint("/ws").addInterceptors(connections.handshakeGuard()) |
|||
.setAllowedOrigins(java.util.Arrays.stream(origins.split(",")) |
|||
.map(String::trim).filter(s -> !s.isEmpty()).toArray(String[]::new)); |
|||
} |
|||
@Override public void configureMessageBroker(MessageBrokerRegistry registry) { |
|||
registry.enableSimpleBroker("/queue").setHeartbeatValue(new long[]{10000, 10000}) |
|||
.setTaskScheduler(chatHeartbeatScheduler()); |
|||
registry.setUserDestinationPrefix("/user"); |
|||
registry.setApplicationDestinationPrefixes("/app"); |
|||
registry.setPreservePublishOrder(true); |
|||
configureBrokerChannel(registry.configureBrokerChannel()); |
|||
} |
|||
@Override public void configureClientInboundChannel(ChannelRegistration registration) { |
|||
registration.interceptors(auth); |
|||
} |
|||
@Override public void configureClientOutboundChannel(ChannelRegistration registration) { |
|||
registration.interceptors(auth.outbound()); |
|||
} |
|||
private void configureBrokerChannel(ChannelRegistration registration) { |
|||
registration.interceptors(new org.springframework.messaging.support.ChannelInterceptor() { |
|||
@Override public void afterSendCompletion(org.springframework.messaging.Message<?> message, |
|||
org.springframework.messaging.MessageChannel channel, boolean sent, Exception exception) { |
|||
var headers = org.springframework.messaging.simp.SimpMessageHeaderAccessor.wrap(message); |
|||
if (!sent || exception != null |
|||
|| headers.getMessageType() != org.springframework.messaging.simp.SimpMessageType.SUBSCRIBE |
|||
|| headers.getDestination() == null |
|||
|| !headers.getDestination().startsWith("/queue/chat-events-user") |
|||
|| headers.getUser() == null || headers.getSessionId() == null) return; |
|||
// 同步 broker channel 返回时订阅已经登记。客户端收到 READY 后再补拉,
|
|||
// 避免“补拉已结束但订阅尚未生效”的窗口漏掉消息。
|
|||
var ready = org.springframework.messaging.simp.SimpMessageHeaderAccessor.create( |
|||
org.springframework.messaging.simp.SimpMessageType.MESSAGE); |
|||
ready.setSessionId(headers.getSessionId()); ready.setLeaveMutable(true); |
|||
socket.getObject().convertAndSendToUser(headers.getUser().getName(), "/queue/chat-events", |
|||
java.util.Map.of("type", "CHAT_READY"), ready.getMessageHeaders()); |
|||
} |
|||
}); |
|||
} |
|||
@Override public void configureWebSocketTransport(WebSocketTransportRegistration registration) { |
|||
registration.setMessageSizeLimit(16384).setSendBufferSizeLimit(262144).setSendTimeLimit(10000) |
|||
.setTimeToFirstMessage(10000).addDecoratorFactory(connections::decorate); |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import com.mydreamjob.auth.AccessTokenClaims; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.scheduling.annotation.Scheduled; |
|||
import org.springframework.web.socket.*; |
|||
import org.springframework.web.socket.handler.WebSocketHandlerDecorator; |
|||
import java.time.Clock; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
|
|||
/** 限制单实例连接数、每用户设备数;定期关闭未鉴权、过期或被撤销的连接。 */ |
|||
@Component @RequiredArgsConstructor |
|||
public class ChatSocketConnections { |
|||
private final ChatSocketAuth auth; |
|||
private final Clock clock; |
|||
private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); |
|||
private final java.util.Map<String, long[]> attempts = new java.util.HashMap<>(); |
|||
|
|||
public org.springframework.web.socket.server.HandshakeInterceptor handshakeGuard() { |
|||
return new org.springframework.web.socket.server.HandshakeInterceptor() { |
|||
@Override public boolean beforeHandshake(org.springframework.http.server.ServerHttpRequest request, |
|||
org.springframework.http.server.ServerHttpResponse response, WebSocketHandler handler, |
|||
java.util.Map<String, Object> attributes) { |
|||
// 凭据不能进入 URL;只接受后续 CONNECT 的 Authorization 头。
|
|||
if (request.getURI().getRawQuery() != null) { |
|||
response.setStatusCode(org.springframework.http.HttpStatus.BAD_REQUEST); return false; |
|||
} |
|||
String source = request.getRemoteAddress() == null ? "unknown" |
|||
: request.getRemoteAddress().getAddress().getHostAddress(); |
|||
synchronized (attempts) { |
|||
long now = clock.millis(); |
|||
attempts.entrySet().removeIf(e -> now - e.getValue()[0] >= 60000); |
|||
if (!attempts.containsKey(source) && attempts.size() >= 1024) { |
|||
response.setStatusCode(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS); return false; |
|||
} |
|||
var count = attempts.computeIfAbsent(source, key -> new long[]{now, 0}); |
|||
if (++count[1] > 40) { |
|||
response.setStatusCode(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS); return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
@Override public void afterHandshake(org.springframework.http.server.ServerHttpRequest request, |
|||
org.springframework.http.server.ServerHttpResponse response, WebSocketHandler handler, Exception exception) { } |
|||
}; |
|||
} |
|||
|
|||
public WebSocketHandler decorate(WebSocketHandler handler) { |
|||
return new WebSocketHandlerDecorator(handler) { |
|||
@Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { |
|||
synchronized (sessions) { |
|||
if (sessions.size() >= 500) { session.close(CloseStatus.POLICY_VIOLATION); return; } |
|||
session.getAttributes().put("chat.openedAt", clock.millis()); |
|||
sessions.put(session.getId(), session); |
|||
} |
|||
try { super.afterConnectionEstablished(session); } |
|||
catch (Exception ex) { sessions.remove(session.getId()); throw ex; } |
|||
} |
|||
@Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { |
|||
sessions.remove(session.getId()); |
|||
super.afterConnectionClosed(session, status); |
|||
} |
|||
}; |
|||
} |
|||
|
|||
@Scheduled(fixedDelay = 10000) |
|||
public void expire() { |
|||
var perUser = new java.util.HashMap<Long, Integer>(); |
|||
sessions.values().forEach(session -> { |
|||
try { |
|||
var claims = (AccessTokenClaims) session.getAttributes().get(ChatSocketAuth.CLAIMS); |
|||
boolean invalid = claims == null |
|||
? clock.millis() - (long) session.getAttributes().getOrDefault("chat.openedAt", 0L) > 10000 |
|||
: !auth.active(claims) || perUser.merge(claims.userId(), 1, Integer::sum) > 5; |
|||
if (invalid) session.close(CloseStatus.POLICY_VIOLATION); |
|||
} catch (Exception ignored) { |
|||
try { session.close(CloseStatus.SERVER_ERROR); } catch (Exception closeIgnored) { } |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
ALTER TABLE messages ADD COLUMN client_message_id VARCHAR(64) NULL; |
|||
ALTER TABLE messages ADD COLUMN request_content_hash VARCHAR(64) NULL; |
|||
CREATE UNIQUE INDEX uk_messages_client_id ON messages(conversation_id, sender_user_id, client_message_id); |
|||
|
|||
-- 不复制聊天正文;补拉时读取消息当前状态,避免已撤回正文重新出现。 |
|||
CREATE TABLE chat_events ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
conversation_id BIGINT NOT NULL, |
|||
recipient_user_id BIGINT NOT NULL, |
|||
actor_user_id BIGINT NOT NULL, |
|||
message_id BIGINT NOT NULL, |
|||
event_type VARCHAR(32) NOT NULL, |
|||
created_at DATETIME(6) NOT NULL, |
|||
CONSTRAINT fk_chat_event_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_chat_event_recipient FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_chat_event_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, |
|||
INDEX idx_chat_events_cursor (conversation_id, recipient_user_id, id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.common; |
|||
|
|||
import com.mydreamjob.config.OpenApiSecurityConfig; |
|||
import com.mydreamjob.controller.ApiController; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.mock.web.MockHttpServletRequest; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.mock; |
|||
|
|||
class ProductBrandTest { |
|||
@Test void apiInformationUsesNewBrandAndPreservesTechnicalName() { |
|||
var controller = new ApiController(null, mock(RequestIdAccessor.class)); |
|||
var info = controller.index(new MockHttpServletRequest()).getBody().getData(); |
|||
assertEquals("mydreamjob", info.get("name")); |
|||
assertEquals("轻松聘", info.get("displayName")); |
|||
assertEquals("轻松聘 - 大学生求职服务平台 API", info.get("description")); |
|||
assertFalse(info.toString().contains("职来")); |
|||
} |
|||
@Test void swaggerTitleUsesNewBrand() { |
|||
assertEquals("轻松聘 API", new OpenApiSecurityConfig().homepageOpenAPI().getInfo().getTitle()); |
|||
} |
|||
} |
|||
@ -0,0 +1,202 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.company.*; |
|||
import com.mydreamjob.messaging.*; |
|||
import com.mydreamjob.messaging.dto.SendMessageRequest; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import org.junit.jupiter.api.*; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.web.server.LocalServerPort; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
import org.springframework.web.socket.client.standard.StandardWebSocketClient; |
|||
import org.springframework.web.socket.messaging.WebSocketStompClient; |
|||
import org.springframework.messaging.simp.stomp.*; |
|||
import org.springframework.messaging.converter.StringMessageConverter; |
|||
import java.lang.reflect.Type; |
|||
import java.util.*; |
|||
import java.util.concurrent.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { |
|||
"spring.datasource.url=jdbc:h2:mem:chatrealtime;MODE=MySQL;DB_CLOSE_DELAY=-1", |
|||
"spring.jpa.show-sql=false", "management.server.port=0"}) |
|||
@ActiveProfiles("integration-test") |
|||
class ChatRealtimeIntegrationTest { |
|||
@Autowired MessagingService service; |
|||
@Autowired UserRepository users; |
|||
@Autowired CompanyRepository companies; |
|||
@Autowired ConversationRepository conversations; |
|||
@Autowired ConversationMemberRepository members; |
|||
@Autowired MessageRepository messages; |
|||
@Autowired ChatEventRepository events; |
|||
@Autowired PlatformTransactionManager transactions; |
|||
@Autowired ObjectMapper mapper; |
|||
@Autowired org.springframework.messaging.simp.user.SimpUserRegistry connectedUsers; |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
@org.springframework.boot.test.mock.mockito.SpyBean ChatEventService eventService; |
|||
@LocalServerPort int port; |
|||
Long student, hr, conversation; |
|||
|
|||
@BeforeEach void setup() { |
|||
student = user(User.Role.STUDENT); hr = user(User.Role.COMPANY); |
|||
companies.save(Company.builder().name("实时聊天测试企业").ownerUserId(hr).verified(true).build()); |
|||
conversation = conversations.save(Conversation.builder().createdBy(hr).type("DIRECT").build()).getId(); |
|||
members.save(ConversationMember.builder().conversationId(conversation).userId(student).build()); |
|||
members.save(ConversationMember.builder().conversationId(conversation).userId(hr).build()); |
|||
} |
|||
private Long user(User.Role role) { |
|||
return users.save(User.builder().phone("rt-" + UUID.randomUUID().toString().substring(0, 12)) |
|||
.role(role).roles(Set.of(role)).build()).getId(); |
|||
} |
|||
private SendMessageRequest request(String key, String content) { |
|||
var req = new SendMessageRequest(); req.setConversationId(conversation); |
|||
req.setClientMessageId(key); req.setContent(content); return req; |
|||
} |
|||
@Test void idempotentRetryReturnsSameMessageAndDoesNotRepeatEvents() { |
|||
var first = service.sendMessage(student, request("retry-key", "hello")); |
|||
var second = service.sendMessage(student, request("retry-key", "hello")); |
|||
assertEquals(first.get("id"), second.get("id")); |
|||
var replay = service.replayEvents(conversation, hr, 0, 100); |
|||
assertEquals(1, ((List<?>) replay.get("items")).size()); |
|||
assertThrows(BusinessException.class, () -> service.sendMessage(student, request("retry-key", "changed"))); |
|||
} |
|||
@Test void concurrentRetryStoresOnlyOneMessage() throws Exception { |
|||
var pool = Executors.newFixedThreadPool(2); |
|||
try { |
|||
var a = pool.submit(() -> service.sendMessage(student, request("parallel-key", "same"))); |
|||
var b = pool.submit(() -> service.sendMessage(student, request("parallel-key", "same"))); |
|||
assertEquals(a.get(10, TimeUnit.SECONDS).get("id"), b.get(10, TimeUnit.SECONDS).get("id")); |
|||
} finally { pool.shutdownNow(); } |
|||
} |
|||
@Test void replayPaginatesWithoutLossAndIncludesReadAndRecall() { |
|||
var sent = service.sendMessage(student, request("event-key", "secret")); |
|||
service.markRead(conversation, hr); |
|||
service.recallMessage(student, conversation, (Long) sent.get("id"), null); |
|||
long cursor = 0; var found = new ArrayList<Map<?, ?>>(); |
|||
Map<String, Object> page; |
|||
do { |
|||
page = service.replayEvents(conversation, hr, cursor, 1); |
|||
for (var item : (List<?>) page.get("items")) found.add((Map<?, ?>) item); |
|||
cursor = ((Number) page.get("nextCursor")).longValue(); |
|||
} while ((Boolean) page.get("hasMore")); |
|||
assertEquals(List.of("MESSAGE_CREATED", "MESSAGE_READ", "MESSAGE_RECALLED"), |
|||
found.stream().map(e -> e.get("type")).toList()); |
|||
assertFalse(found.toString().contains("secret")); |
|||
} |
|||
@Test void unauthorizedAndInvalidCursorRejected() { |
|||
Long outsider = user(User.Role.STUDENT); |
|||
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, outsider, 0, 50)); |
|||
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, student, -1, 50)); |
|||
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, student, 0, 101)); |
|||
} |
|||
@Test void rolledBackSendHasNoPersistedEvents() { |
|||
new TransactionTemplate(transactions).execute(status -> { |
|||
service.sendMessage(student, request("rollback-key", "discard")); status.setRollbackOnly(); return null; |
|||
}); |
|||
assertTrue(((List<?>) service.replayEvents(conversation, hr, 0, 50).get("items")).isEmpty()); |
|||
org.mockito.Mockito.verify(eventService, org.mockito.Mockito.never()).push(org.mockito.ArgumentMatchers.any()); |
|||
} |
|||
@Test void invitationReplayDoesNotExposeOtherUserId() { |
|||
var c = conversations.findById(conversation).orElseThrow(); c.setType("TALENT_INVITATION"); conversations.save(c); |
|||
service.sendMessage(student, request("private-key", "hello")); |
|||
var page = mapper.valueToTree(service.replayEvents(conversation, hr, 0, 50)); |
|||
assertFalse(page.path("items").get(0).path("payload").has("senderUserId")); |
|||
assertEquals("OTHER_PARTICIPANT", page.path("items").get(0).path("payload").path("senderScope").asText()); |
|||
} |
|||
@Test void realWebSocketReceivesBothDirectionsAfterCommit() throws Exception { |
|||
var client = new WebSocketStompClient(new StandardWebSocketClient()); |
|||
var scheduler = new org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler(); |
|||
scheduler.initialize(); client.setTaskScheduler(scheduler); |
|||
var converter = new org.springframework.messaging.converter.MappingJackson2MessageConverter(); |
|||
converter.setObjectMapper(mapper); client.setMessageConverter(converter); |
|||
var hrQueue = new LinkedBlockingQueue<String>(); var studentQueue = new LinkedBlockingQueue<String>(); |
|||
StompSession hrSocket = null, studentSocket = null; |
|||
try { |
|||
hrSocket = connect(client, hr, "COMPANY", hrQueue); |
|||
studentSocket = connect(client, student, "STUDENT", studentQueue); |
|||
service.sendMessage(student, request("socket-student", "hello HR")); |
|||
assertEquals(1, ((List<?>) service.replayEvents(conversation, hr, 0, 50).get("items")).size()); |
|||
org.mockito.Mockito.verify(eventService, org.mockito.Mockito.atLeastOnce()).push(org.mockito.ArgumentMatchers.any()); |
|||
assertEquals("hello HR", mapper.readTree(Objects.requireNonNull(hrQueue.poll(10, TimeUnit.SECONDS))) |
|||
.path("payload").path("content").asText()); |
|||
studentQueue.poll(10, TimeUnit.SECONDS); // 发送者自己的设备也接收事件。
|
|||
service.sendMessage(hr, request("socket-hr", "hello student")); |
|||
assertEquals("hello student", mapper.readTree(Objects.requireNonNull(studentQueue.poll(10, TimeUnit.SECONDS))) |
|||
.path("payload").path("content").asText()); |
|||
} finally { |
|||
if (hrSocket != null && hrSocket.isConnected()) hrSocket.disconnect(); |
|||
if (studentSocket != null && studentSocket.isConnected()) studentSocket.disconnect(); |
|||
client.stop(); |
|||
scheduler.shutdown(); |
|||
} |
|||
} |
|||
|
|||
@Test void realReconnectReplaysMoreThanOnePageAndContinuesReceiving() throws Exception { |
|||
var client = new WebSocketStompClient(new StandardWebSocketClient()); |
|||
var scheduler = new org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler(); scheduler.initialize(); |
|||
client.setTaskScheduler(scheduler); |
|||
var converter = new org.springframework.messaging.converter.MappingJackson2MessageConverter(); |
|||
converter.setObjectMapper(mapper); client.setMessageConverter(converter); |
|||
var queue = new LinkedBlockingQueue<String>(); StompSession socket = null; |
|||
try { |
|||
socket = connect(client, hr, "COMPANY", queue); |
|||
service.sendMessage(student, request("online", "before disconnect")); |
|||
assertNotNull(queue.poll(10, TimeUnit.SECONDS)); |
|||
long cursor = ((Number) service.replayEvents(conversation, hr, 0, 50).get("nextCursor")).longValue(); |
|||
socket.disconnect(); |
|||
org.awaitility.Awaitility.await().atMost(10, TimeUnit.SECONDS) |
|||
.until(() -> connectedUsers.getUser(hr.toString()) == null); |
|||
for (int i = 0; i < 55; i++) service.sendMessage(student, request("offline-" + i, "offline " + i)); |
|||
socket = connect(client, hr, "COMPANY", queue); |
|||
var ids = new HashSet<Object>(); boolean hasMore; |
|||
do { |
|||
var page = service.replayEvents(conversation, hr, cursor, 10); |
|||
for (var value : (List<?>) page.get("items")) { |
|||
var event = (Map<?, ?>) value; |
|||
assertTrue(ids.add(event.get("eventId")), "不能重复补拉事件"); |
|||
} |
|||
cursor = ((Number) page.get("nextCursor")).longValue(); |
|||
hasMore = (Boolean) page.get("hasMore"); |
|||
} while (hasMore); |
|||
assertEquals(55, ids.size()); |
|||
queue.clear(); |
|||
service.sendMessage(student, request("after-reconnect", "live again")); |
|||
assertEquals("live again", mapper.readTree(Objects.requireNonNull(queue.poll(10, TimeUnit.SECONDS))) |
|||
.path("payload").path("content").asText()); |
|||
} finally { |
|||
if (socket != null && socket.isConnected()) socket.disconnect(); |
|||
client.stop(); scheduler.shutdown(); |
|||
} |
|||
} |
|||
private StompSession connect(WebSocketStompClient client, Long user, String role, |
|||
BlockingQueue<String> queue) throws Exception { |
|||
var headers = new StompHeaders(); headers.set("Authorization", "Bearer " + TestAuthTokens.accessToken(user, role)); |
|||
var session = client.connectAsync("ws://localhost:" + port + "/api/v1/ws", new org.springframework.web.socket.WebSocketHttpHeaders(), headers, |
|||
new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); |
|||
session.subscribe("/user/queue/chat-events", new StompFrameHandler() { |
|||
@Override public Type getPayloadType(StompHeaders h) { return Map.class; } |
|||
@Override public void handleFrame(StompHeaders h, Object payload) { |
|||
try { queue.add(mapper.writeValueAsString(payload)); } |
|||
catch (Exception ex) { throw new IllegalStateException("Invalid event payload", ex); } |
|||
} |
|||
}); |
|||
// 内置 broker 不支持 STOMP receipts;通过真实服务端订阅注册作为测试屏障。
|
|||
org.awaitility.Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> { |
|||
var connected = connectedUsers.getUser(user.toString()); |
|||
return connected != null && connected.getSessions().stream() |
|||
.anyMatch(s -> !s.getSubscriptions().isEmpty()); |
|||
}); |
|||
var ready = queue.poll(10, TimeUnit.SECONDS); |
|||
assertNotNull(ready, "必须收到订阅就绪帧再补拉历史"); |
|||
assertEquals("CHAT_READY", mapper.readTree(ready).path("type").asText()); |
|||
return session; |
|||
} |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import org.junit.jupiter.api.*; |
|||
import org.springframework.messaging.support.MessageBuilder; |
|||
import org.springframework.messaging.simp.stomp.*; |
|||
import java.time.*; |
|||
import java.util.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
class ChatSocketAuthTest { |
|||
private final Instant now = Instant.parse("2026-10-14T08:00:00Z"); |
|||
private JwtTokenProvider tokens; |
|||
private AuthSessionService sessions; |
|||
private UserRepository users; |
|||
private ChatSocketAuth guard; |
|||
private AccessTokenClaims claims; |
|||
|
|||
@BeforeEach void setup() { |
|||
tokens = mock(JwtTokenProvider.class); sessions = mock(AuthSessionService.class); |
|||
users = mock(UserRepository.class); |
|||
guard = new ChatSocketAuth(tokens, sessions, users, |
|||
mock(com.mydreamjob.company.CompanyRepository.class), Clock.fixed(now, ZoneOffset.UTC)); |
|||
claims = new AccessTokenClaims(1L, "session", "token", "STUDENT", now.minusSeconds(1), now.plusSeconds(60)); |
|||
when(tokens.parseAndValidateAccessToken("valid", now)).thenReturn(claims); |
|||
when(sessions.isSessionActive(1L, "session", now)).thenReturn(true); |
|||
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().id(1L).build())); |
|||
} |
|||
private StompHeaderAccessor frame(StompCommand command, Map<String, Object> attrs) { |
|||
var frame = StompHeaderAccessor.create(command); |
|||
frame.setSessionId("ws-1"); frame.setSessionAttributes(attrs); frame.setLeaveMutable(true); |
|||
return frame; |
|||
} |
|||
private void send(StompHeaderAccessor frame) { |
|||
guard.preSend(MessageBuilder.createMessage(new byte[0], frame.getMessageHeaders()), null); |
|||
} |
|||
private Map<String, Object> connect() { |
|||
var attrs = new HashMap<String, Object>(); var frame = frame(StompCommand.CONNECT, attrs); |
|||
frame.setNativeHeader("Authorization", "Bearer valid"); send(frame); return attrs; |
|||
} |
|||
@Test void connectAuthenticatesAndRemovesRawToken() { |
|||
var frame = frame(StompCommand.CONNECT, new HashMap<>()); |
|||
frame.setNativeHeader("Authorization", "Bearer valid"); send(frame); |
|||
assertEquals("1", frame.getUser().getName()); |
|||
assertNull(frame.getFirstNativeHeader("Authorization")); |
|||
} |
|||
@Test void anonymousConnectRejected() { |
|||
assertThrows(Exception.class, () -> send(frame(StompCommand.CONNECT, new HashMap<>()))); |
|||
} |
|||
@Test void revokedSessionRejected() { |
|||
when(sessions.isSessionActive(1L, "session", now)).thenReturn(false); |
|||
assertThrows(Exception.class, this::connect); |
|||
} |
|||
@Test void expiredTokenRejected() { |
|||
when(tokens.parseAndValidateAccessToken("valid", now)).thenReturn( |
|||
new AccessTokenClaims(1L, "session", "token", "STUDENT", now.minusSeconds(60), now)); |
|||
assertThrows(Exception.class, this::connect); |
|||
} |
|||
@Test void unassignedIdentityRejected() { |
|||
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().role(User.Role.UNASSIGNED).build())); |
|||
assertThrows(Exception.class, this::connect); |
|||
} |
|||
@Test void ownFixedQueueAllowed() { |
|||
var frame = frame(StompCommand.SUBSCRIBE, connect()); |
|||
frame.setDestination("/user/queue/chat-events"); assertDoesNotThrow(() -> send(frame)); |
|||
} |
|||
@Test void otherUserAndBrokerQueuesRejected() { |
|||
var attrs = connect(); |
|||
for (String path : List.of("/user/2/queue/chat-events", "/queue/chat-events", "/topic/all")) { |
|||
var frame = frame(StompCommand.SUBSCRIBE, attrs); frame.setDestination(path); |
|||
assertThrows(Exception.class, () -> send(frame)); |
|||
} |
|||
} |
|||
@Test void socketSendRejected() { |
|||
var frame = frame(StompCommand.SEND, connect()); frame.setDestination("/app/chat"); |
|||
assertThrows(Exception.class, () -> send(frame)); |
|||
} |
|||
@Test void databaseFailureFailsClosed() { |
|||
when(users.findByIdWithRoles(1L)).thenThrow(new IllegalStateException("private detail")); |
|||
var error = assertThrows(Exception.class, this::connect); |
|||
assertEquals("Chat connection rejected", error.getMessage()); |
|||
} |
|||
@Test void repeatedConnectRejected() { |
|||
var frame = frame(StompCommand.CONNECT, connect()); frame.setNativeHeader("Authorization", "Bearer valid"); |
|||
assertThrows(Exception.class, () -> send(frame)); |
|||
} |
|||
@Test void unverifiedCompanyRejected() { |
|||
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().id(1L) |
|||
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build())); |
|||
assertThrows(Exception.class, this::connect); |
|||
} |
|||
@Test void revokedSessionCannotSubscribeAgain() { |
|||
var attrs = connect(); when(sessions.isSessionActive(1L, "session", now)).thenReturn(false); |
|||
var frame = frame(StompCommand.SUBSCRIBE, attrs); frame.setDestination("/user/queue/chat-events"); |
|||
assertThrows(Exception.class, () -> send(frame)); |
|||
} |
|||
@Test void subscriptionFloodRejected() { |
|||
var attrs = connect(); var frame = frame(StompCommand.SUBSCRIBE, attrs); |
|||
frame.setDestination("/user/queue/chat-events"); |
|||
for (int i = 0; i < 119; i++) send(frame); |
|||
assertThrows(Exception.class, () -> send(frame)); |
|||
} |
|||
@Test void outboundChecksRevocationBeforeDelivery() { |
|||
connect(); |
|||
var headers = org.springframework.messaging.simp.SimpMessageHeaderAccessor.create( |
|||
org.springframework.messaging.simp.SimpMessageType.MESSAGE); |
|||
headers.setSessionId("ws-1"); |
|||
var message = MessageBuilder.createMessage(new byte[0], headers.getMessageHeaders()); |
|||
assertNotNull(guard.outbound().preSend(message, null)); |
|||
when(sessions.isSessionActive(1L, "session", now)).thenReturn(false); |
|||
assertNull(guard.outbound().preSend(message, null)); |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
package com.mydreamjob.messaging.realtime; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.mock.web.*; |
|||
import org.springframework.http.server.*; |
|||
import org.springframework.web.socket.*; |
|||
import java.time.*; |
|||
import java.util.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
class ChatSocketConnectionsTest { |
|||
private final Clock clock = Clock.fixed(Instant.parse("2026-10-14T08:00:00Z"), ZoneOffset.UTC); |
|||
@Test void urlCredentialsRejected() throws Exception { |
|||
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock); |
|||
var request = new MockHttpServletRequest("GET", "/ws"); request.setQueryString("token=secret"); |
|||
var response = new MockHttpServletResponse(); |
|||
assertFalse(connections.handshakeGuard().beforeHandshake(new ServletServerHttpRequest(request), |
|||
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>())); |
|||
assertEquals(400, response.getStatus()); |
|||
} |
|||
@Test void handshakeFloodRejected() throws Exception { |
|||
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock); |
|||
var guard = connections.handshakeGuard(); var request = new MockHttpServletRequest("GET", "/ws"); |
|||
var response = new MockHttpServletResponse(); |
|||
for (int i = 0; i < 40; i++) assertTrue(guard.beforeHandshake(new ServletServerHttpRequest(request), |
|||
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>())); |
|||
assertFalse(guard.beforeHandshake(new ServletServerHttpRequest(request), |
|||
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>())); |
|||
assertEquals(429, response.getStatus()); |
|||
} |
|||
@Test void unauthenticatedConnectionIsClosedAndReleased() throws Exception { |
|||
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock); |
|||
var session = mock(WebSocketSession.class); var attrs = new HashMap<String, Object>(); |
|||
when(session.getId()).thenReturn("ws-1"); when(session.getAttributes()).thenReturn(attrs); |
|||
var handler = connections.decorate(mock(WebSocketHandler.class)); |
|||
handler.afterConnectionEstablished(session); |
|||
attrs.put("chat.openedAt", clock.millis() - 20000); |
|||
connections.expire(); verify(session).close(CloseStatus.POLICY_VIOLATION); |
|||
handler.afterConnectionClosed(session, CloseStatus.POLICY_VIOLATION); |
|||
connections.expire(); verify(session, times(1)).close(CloseStatus.POLICY_VIOLATION); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue