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:
2026-10-21 19:18:02 +08:00
parent 31eb75cdad
commit bf6813e71d
8 changed files with 469 additions and 108 deletions
@@ -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);
}
}