feat(ai): 聊天 WebSocket 加 /api 端点与心跳,WS 不可用时退到 HTTP SSE
生产网关在 /api 前丢掉 Upgrade 头,wss://…/chat/{userId} 到不了后端,
端上一直卡在「正在建立 WebSocket 连接…」。
- 新增 ApiChatWebSocketServer(/api/chat/{userId}),与老端点共用会话表;
会话表改存 Session 而不是端点实例,两条路径都能收到推送
- 登记会话时关闭空闲超时(Tomcat 默认 20s,AI 首字慢时会被掐断),
并补 @OnMessage 丢弃心跳(缺了它容器会因不支持的文本帧直接断连)
- send() 找不到 WS 会话时改投 ChatStreamRegistry,把帧送到 HTTP SSE 连接,
推送侧仍只有 sendMessage 一个入口,三千多次调用点不用改
- prod 放开 com.gxwebsoft.ai.config 到 INFO:只保留连接/断开,推送路径仍静默
- 补 ChatStreamRegistryTest / WebSocketEndpointPathTest / AiControllerSseFrameTest
顺带补上 31eb75c 漏加的 ChatStreamRegistry.java:AiController 已经 import 它,
缺文件时 HEAD 编译不过。
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package com.gxwebsoft.ai.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* HTTP 流式通道(SSE)是生产上唯一能用的推送路径:网关在 {@code /api} 前丢掉
|
||||
* WebSocket 的 {@code Upgrade} 头,WS 握手永远到不了 Tomcat。
|
||||
*
|
||||
* <p>因此这张登记表的几个行为是硬约束:没人接时静默、有人接时逐字转发、
|
||||
* 重复登记后者覆盖前者、写失败要把死掉的 sink 摘掉
|
||||
* (否则后续消息会一直往已经断开的长连接里写)。
|
||||
*
|
||||
* <p>每个用例用独立 userId,避免静态表互相串味,也就不需要清理。
|
||||
*/
|
||||
class ChatStreamRegistryTest {
|
||||
|
||||
private static String newUser() {
|
||||
return "test-" + UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 没有登记通道时返回false() {
|
||||
assertFalse(ChatStreamRegistry.emit(newUser(), "{}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 登记后能收到原文() {
|
||||
String user = newUser();
|
||||
List<String> received = new ArrayList<>();
|
||||
ChatStreamRegistry.bind(user, received::add);
|
||||
|
||||
assertTrue(ChatStreamRegistry.emit(user, "{\"answer\":\"你好\"}"));
|
||||
assertEquals(Collections.singletonList("{\"answer\":\"你好\"}"), received);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 重复登记时后者覆盖前者() {
|
||||
String user = newUser();
|
||||
List<String> first = new ArrayList<>();
|
||||
List<String> second = new ArrayList<>();
|
||||
ChatStreamRegistry.bind(user, first::add);
|
||||
ChatStreamRegistry.bind(user, second::add);
|
||||
|
||||
ChatStreamRegistry.emit(user, "x");
|
||||
|
||||
assertTrue(first.isEmpty(), "被覆盖的旧连接不该再收到消息");
|
||||
assertEquals(Collections.singletonList("x"), second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 解绑只摘自己的那个通道() {
|
||||
String user = newUser();
|
||||
List<String> first = new ArrayList<>();
|
||||
List<String> second = new ArrayList<>();
|
||||
Consumer<String> firstSink = first::add;
|
||||
ChatStreamRegistry.bind(user, firstSink);
|
||||
ChatStreamRegistry.bind(user, second::add);
|
||||
|
||||
// 旧连接晚到的 finally 块:不能把新连接顶掉
|
||||
ChatStreamRegistry.unbind(user, firstSink);
|
||||
|
||||
assertTrue(ChatStreamRegistry.emit(user, "still-alive"));
|
||||
assertEquals(Collections.singletonList("still-alive"), second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 写入失败后摘掉通道且不向上抛() {
|
||||
String user = newUser();
|
||||
ChatStreamRegistry.bind(user, payload -> {
|
||||
throw new IllegalStateException("客户端已断开");
|
||||
});
|
||||
|
||||
assertFalse(ChatStreamRegistry.emit(user, "boom"));
|
||||
// 失败过的 sink 已被摘掉,下一次 emit 直接返回 false
|
||||
assertFalse(ChatStreamRegistry.emit(user, "boom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 没有WebSocket会话时推送落到流式通道() throws IOException {
|
||||
String user = newUser();
|
||||
List<String> received = new ArrayList<>();
|
||||
ChatStreamRegistry.bind(user, received::add);
|
||||
|
||||
// 生产就是这样:SESSIONS 里没有这个用户,send 必须改投 SSE
|
||||
WebSocketServer.send(user, "{\"answer\":\"fallback\"}");
|
||||
|
||||
assertEquals(Collections.singletonList("{\"answer\":\"fallback\"}"), received);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user