fix(ai): WebSocket 推送静默化,并补重试与静默推送用例(issue 11 / 12)

- WebSocketServer.sendMessage 里两行 System.out.println 删除。该方法被**每个 SSE
  事件**调用一次(大纲单次生成实测 3062 个事件,即一次请求刷 6000+ 行),而且绕过
  日志框架——既不受 logging.level 控制(生产把 com.gxwebsoft 压到 ERROR 也拦不住),
  也不进日志文件,真故障时需要的几行会被顶掉。日志改到低频的 onOpen / onClose;
  同时把 onOpen 里静默吞掉的握手回应异常改成 WARN(那是「连上了却收不到内容」的
  第一现场)。
- 新增用例:AiCreativeRetryTest、AiCreativeRetryIntegrationTest(issue 11,
  后者用本地 HTTP server 扮演 Dify,断言瞬时故障确实重跑一次、凭证失效只请求一次)、
  WebSocketServerSilentPushTest(issue 12,捕获 System.out 断言推送路径零输出)。
This commit is contained in:
2026-10-13 20:00:01 +08:00
parent 8406c90cf3
commit 21e49c6a1a
4 changed files with 532 additions and 88 deletions
@@ -0,0 +1,82 @@
package com.gxwebsoft.ai.config;
import org.junit.jupiter.api.Test;
import javax.websocket.RemoteEndpoint;
import javax.websocket.Session;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Callable;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* WebSocket 推送是**高频路径**,不得往标准输出打印。
*
* <p>两条 AI 流式链路(创意助手、活动策划大纲)每个 SSE 事件都会调一次
* {@link WebSocketServer#sendMessage(String, String)}:实测一次大纲生成 3062 个事件,
* 即单次请求就会调用 3000+ 次。最初这里有两行 {@code System.out.println}
* ({@code userId} 与 {@code session1}),于是一次生成就在控制台刷出上万行
* 「userId:833」,既刷屏又绕过日志级别(见 .scratch/ai-assistant/issues/12)。
*
* <p>两个分支(目标已连接 / 未连接)各锁一条:改回 {@code println} 立刻失败。
*/
class WebSocketServerSilentPushTest {
@Test
void 目标未连接时推送不得打印() throws Exception {
String output = captureStdout(() -> {
// 未连接的 userId:走"查不到会话"的分支,也正是刷屏时最常见的那条
new WebSocketServer().sendMessage("silent-push-absent", "{\"answer\":\"测试\"}");
return null;
});
assertEquals("", output, "推送是高频路径,不能刷标准输出");
}
@Test
void 目标已连接时推送也不得打印() throws Exception {
WebSocketServer server = new WebSocketServer();
// onOpen 会打一条 INFO(连接日志,低频且应当保留),故放在捕获之外
server.onOpen(fakeSession(), "silent-push-open");
String output = captureStdout(() -> {
server.sendMessage("silent-push-open", "{\"answer\":\"测试\"}");
return null;
});
assertEquals("", output, "推送是高频路径,不能刷标准输出");
}
/**
* 捕获一段代码往标准输出写的内容。推送是 void 且抛受检异常,故用 {@link Callable}。
*/
private static String captureStdout(Callable<Void> action) throws Exception {
PrintStream original = System.out;
ByteArrayOutputStream captured = new ByteArrayOutputStream();
try {
System.setOut(new PrintStream(captured, true, StandardCharsets.UTF_8.name()));
action.call();
} finally {
System.setOut(original);
}
return captured.toString(StandardCharsets.UTF_8.name());
}
/**
* 一个什么都不做的 {@link Session}:只为让 {@code sendMessage} 走进"已连接"分支。
*/
private static Session fakeSession() {
return (Session) Proxy.newProxyInstance(Session.class.getClassLoader(),
new Class<?>[]{Session.class}, (proxy, method, args) -> {
if ("getBasicRemote".equals(method.getName())) {
return Proxy.newProxyInstance(RemoteEndpoint.Basic.class.getClassLoader(),
new Class<?>[]{RemoteEndpoint.Basic.class},
(endpoint, call, callArgs) -> null);
}
return null;
});
}
}