diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHMessageType.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHMessageType.java index 6e5c21c4..4c533187 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHMessageType.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/enums/SSHMessageType.java @@ -3,30 +3,36 @@ package com.qqchen.deploy.backend.framework.enums; import com.fasterxml.jackson.annotation.JsonValue; /** - * SSH WebSocket消息类型枚举(Framework层) + * SSH WebSocket 消息类型枚举(Framework层) * - * 用于标识不同类型的SSH WebSocket消息 + * 定义标准的SSH WebSocket消息类型 + * 使用 @JsonValue 注解实现JSON序列化为小写字符串 */ public enum SSHMessageType { /** - * 用户输入(前端 → 后端) + * 用户输入消息(前端 → 后端) */ INPUT("input"), /** - * 终端输出(后端 → 前端) + * SSH输出消息(后端 → 前端) */ OUTPUT("output"), /** - * 连接状态(后端 → 前端) + * 连接状态消息(后端 → 前端) */ STATUS("status"), /** - * 错误信息(后端 → 前端) + * 错误消息(后端 → 前端) */ - ERROR("error"); + ERROR("error"), + + /** + * 终端尺寸调整消息(前端 → 后端) + */ + RESIZE("resize"); private final String value; diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/AbstractSSHWebSocketHandler.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/AbstractSSHWebSocketHandler.java index 4f89b266..6eb566ec 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/AbstractSSHWebSocketHandler.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/AbstractSSHWebSocketHandler.java @@ -310,28 +310,84 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { SSHWebSocketMessage msg = JsonUtils.fromJson(message.getPayload(), SSHWebSocketMessage.class); if (msg.getType() == SSHMessageType.INPUT) { - // 用户输入命令 + // 用户输入命令(使用强类型Request) + com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHInputRequest request = + msg.getRequest(com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHInputRequest.class); + + if (request == null || !request.isValid()) { + log.warn("INPUT消息格式错误或为空: sessionId={}", sessionId); + return; + } + Session.Shell shell = sshShells.get(sessionId); if (shell != null) { OutputStream outputStream = shell.getOutputStream(); - outputStream.write(msg.getData().getBytes(StandardCharsets.UTF_8)); + outputStream.write(request.getCommand().getBytes(StandardCharsets.UTF_8)); outputStream.flush(); // 触发命令事件 SSHTarget target = sessionTargets.get(sessionId); SSHEventData eventData = SSHEventData.builder() .sessionId(sessionId) - .command(msg.getData()) + .command(request.getCommand()) .target(target) .build(); onEvent(SSHEvent.ON_COMMAND, eventData); } + } else if (msg.getType() == SSHMessageType.RESIZE) { + // 终端尺寸调整 + handleResizeMessage(sessionId, msg); } } catch (Exception e) { log.error("处理WebSocket消息失败: sessionId={}", sessionId, e); } } + /** + * 处理终端尺寸调整消息(使用强类型Request) + * + * @param sessionId 会话ID + * @param msg 消息对象 + */ + private void handleResizeMessage(String sessionId, SSHWebSocketMessage msg) { + try { + // 使用强类型提取request + com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHResizeRequest request = + msg.getRequest(com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHResizeRequest.class); + + if (request == null) { + log.warn("RESIZE消息缺少request或格式错误: sessionId={}", sessionId); + return; + } + + // 验证参数 + if (!request.isValid()) { + log.warn("RESIZE消息参数无效: sessionId={}, rows={}, cols={}", + sessionId, request.getRows(), request.getCols()); + return; + } + + // 调整SSH PTY尺寸 + Session.Shell shell = sshShells.get(sessionId); + if (shell != null) { + // 计算像素尺寸(标准字体:8x16像素) + int widthPixels = request.getCols() * 8; + int heightPixels = request.getRows() * 16; + + shell.changeWindowDimensions(request.getCols(), request.getRows(), + widthPixels, heightPixels); + + log.debug("SSH终端尺寸已调整: sessionId={}, cols={}, rows={}", + sessionId, request.getCols(), request.getRows()); + } else { + log.warn("未找到SSH Shell,无法调整尺寸: sessionId={}", sessionId); + } + + } catch (Exception e) { + log.error("处理RESIZE消息失败: sessionId={}", sessionId, e); + } + } + @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String sessionId = getSessionId(session); @@ -624,7 +680,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { } /** - * 发送输出消息到前端 + * 发送输出消息到前端(使用强类型Response) * * @param session WebSocket会话 * @param output 输出内容 @@ -640,8 +696,16 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { return; } - log.debug(" ├─ 创建SSHWebSocketMessage: sessionId={}", sessionId); - SSHWebSocketMessage msg = SSHWebSocketMessage.output(output); + // 创建强类型Response对象 + com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHOutputResponse response = + new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHOutputResponse(output); + + // 包装成 {"response": SSHOutputResponse} + Map data = new HashMap<>(); + data.put("response", response); + + // 创建消息 + SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.OUTPUT, data); log.debug(" ├─ 准备调用session.sendMessage: sessionId={}", sessionId); session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); @@ -655,11 +719,25 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { } /** - * 发送状态消息到前端 + * 发送状态消息到前端(使用强类型Response) */ protected void sendStatus(WebSocketSession session, SSHStatusEnum status) { try { - SSHWebSocketMessage msg = SSHWebSocketMessage.status(status); + if (!session.isOpen()) { + log.debug("WebSocket已关闭,跳过发送状态: sessionId={}", session.getId()); + return; + } + + // 创建强类型Response对象 + com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHStatusResponse response = + new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHStatusResponse(status); + + // 包装成 {"response": SSHStatusResponse} + Map data = new HashMap<>(); + data.put("response", response); + + // 创建消息 + SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.STATUS, data); session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); } catch (IOException e) { log.error("发送状态消息失败: sessionId={}", session.getId(), e); @@ -667,7 +745,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { } /** - * 发送错误消息到前端 + * 发送错误消息到前端(使用强类型Response) */ protected void sendError(WebSocketSession session, String error) { try { @@ -675,7 +753,17 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler { log.debug("WebSocket已关闭,跳过发送错误消息: sessionId={}", session.getId()); return; } - SSHWebSocketMessage msg = SSHWebSocketMessage.error(error); + + // 创建强类型Response对象 + com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHErrorResponse response = + new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHErrorResponse(error); + + // 包装成 {"response": SSHErrorResponse} + Map data = new HashMap<>(); + data.put("response", response); + + // 创建消息 + SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.ERROR, data); session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); } catch (IOException e) { if (session.isOpen()) { diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/SSHWebSocketMessage.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/SSHWebSocketMessage.java index 6aadae2b..1a379da3 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/SSHWebSocketMessage.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/SSHWebSocketMessage.java @@ -1,6 +1,7 @@ package com.qqchen.deploy.backend.framework.ssh.websocket; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; import com.qqchen.deploy.backend.framework.enums.SSHMessageType; import com.qqchen.deploy.backend.framework.enums.SSHStatusEnum; import lombok.Data; @@ -30,13 +31,23 @@ public class SSHWebSocketMessage { private SSHMessageType type; /** - * 消息内容(字符串) + * 消息内容(可以是字符串或对象) + * + * 字符串类型(后端→前端): * - type=OUTPUT: 终端输出内容 - * - type=INPUT: 用户输入内容 * - type=STATUS: 状态值(connecting/connected/reconnecting/disconnected/error) * - type=ERROR: 错误描述信息 + * + * 字符串类型(前端→后端): + * - type=INPUT: 用户输入内容 + * + * 对象类型(前端→后端): + * - type=RESIZE: {"request": {"rows": 40, "cols": 150}} + * + * 对象类型(后端→前端,预留): + * - 未来可能的response: {"response": {...}} */ - private String data; + private Object data; /** * 消息时间戳(Unix毫秒) @@ -46,14 +57,15 @@ public class SSHWebSocketMessage { /** * 可选元数据 - * 通常为null,特殊场景下使用 + * + * 用于特殊场景的扩展字段,通常为null */ private Map metadata; /** * 构造函数(自动填充时间戳) */ - public SSHWebSocketMessage(SSHMessageType type, String data) { + public SSHWebSocketMessage(SSHMessageType type, Object data) { this.type = type; this.data = data; this.timestamp = System.currentTimeMillis(); @@ -94,4 +106,40 @@ public class SSHWebSocketMessage { public static SSHWebSocketMessage input(String data) { return new SSHWebSocketMessage(SSHMessageType.INPUT, data); } + + /** + * 从消息中提取request对象(强类型转换) + * + * @param requestClass 请求类型 + * @return 请求对象,如果解析失败返回null + */ + @SuppressWarnings("unchecked") + public T getRequest(Class requestClass) { + try { + if (data instanceof Map) { + Map dataMap = (Map) data; + Object requestObj = dataMap.get("request"); + + if (requestObj != null) { + // 如果已经是目标类型,直接返回 + if (requestClass.isInstance(requestObj)) { + return (T) requestObj; + } + // 否则使用ObjectMapper转换(Map -> POJO) + ObjectMapper mapper = new ObjectMapper(); + return mapper.convertValue(requestObj, requestClass); + } + } + } catch (Exception e) { + // 转换失败,返回null + } + return null; + } + + /** + * 检查data是否包含request + */ + public boolean hasRequest() { + return data instanceof Map && ((Map) data).containsKey("request"); + } } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHBaseRequest.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHBaseRequest.java new file mode 100644 index 00000000..55c9687c --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHBaseRequest.java @@ -0,0 +1,29 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.request; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; + +/** + * SSH WebSocket请求基类(Framework层) + * + * 所有前端发送到后端的请求消息都应该继承此类 + * 使用Jackson的@JsonTypeInfo和@JsonSubTypes实现多态反序列化 + */ +@Data +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SSHInputRequest.class, name = "input"), + @JsonSubTypes.Type(value = SSHResizeRequest.class, name = "resize") +}) +public abstract class SSHBaseRequest { + /** + * 请求类型(使用枚举保证类型安全) + */ + private SSHMessageType type; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHInputRequest.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHInputRequest.java new file mode 100644 index 00000000..d54d9e90 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHInputRequest.java @@ -0,0 +1,45 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.request; + +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * SSH用户输入请求(Framework层) + * + * 前端发送格式: + * { + * "type": "input", + * "data": { + * "request": { + * "type": "input", + * "command": "ls -la" + * } + * } + * } + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SSHInputRequest extends SSHBaseRequest { + + /** + * 用户输入命令(终端命令或字符) + */ + private String command; + + public SSHInputRequest() { + setType(SSHMessageType.INPUT); + } + + public SSHInputRequest(String command) { + this(); + this.command = command; + } + + /** + * 验证参数有效性 + */ + public boolean isValid() { + return command != null && !command.isEmpty(); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHResizeRequest.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHResizeRequest.java new file mode 100644 index 00000000..bbbeed21 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/request/SSHResizeRequest.java @@ -0,0 +1,45 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.request; + +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * SSH终端尺寸调整请求(Framework层) + * + * 前端发送格式: + * { + * "type": "resize", + * "data": { + * "request": { + * "rows": 40, + * "cols": 150 + * } + * } + * } + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SSHResizeRequest extends SSHBaseRequest { + + /** + * 终端行数 + */ + private Integer rows; + + /** + * 终端列数 + */ + private Integer cols; + + public SSHResizeRequest() { + setType(SSHMessageType.RESIZE); + } + + /** + * 验证参数有效性 + */ + public boolean isValid() { + return rows != null && cols != null && rows > 0 && cols > 0; + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHBaseResponse.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHBaseResponse.java new file mode 100644 index 00000000..97eb1b47 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHBaseResponse.java @@ -0,0 +1,38 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.response; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; + +/** + * SSH WebSocket响应基类(Framework层) + * + * 所有后端发送到前端的响应消息都应该继承此类 + * 使用Jackson的@JsonTypeInfo和@JsonSubTypes实现多态序列化 + */ +@Data +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = SSHOutputResponse.class, name = "output"), + @JsonSubTypes.Type(value = SSHStatusResponse.class, name = "status"), + @JsonSubTypes.Type(value = SSHErrorResponse.class, name = "error") +}) +public abstract class SSHBaseResponse { + /** + * 响应类型(使用枚举保证类型安全) + */ + private SSHMessageType type; + + /** + * 响应数据(统一字段) + * - OUTPUT: 终端输出内容 + * - STATUS: 状态值(connected/disconnected等) + * - ERROR: 错误消息 + */ + private String data; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHErrorResponse.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHErrorResponse.java new file mode 100644 index 00000000..43ec4e21 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHErrorResponse.java @@ -0,0 +1,34 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.response; + +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * SSH错误响应(Framework层) + * + * 后端发送格式: + * { + * "type": "error", + * "data": { + * "response": { + * "type": "error", + * "data": "Connection failed" + * } + * } + * } + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SSHErrorResponse extends SSHBaseResponse { + + public SSHErrorResponse() { + setType(SSHMessageType.ERROR); + } + + public SSHErrorResponse(String message) { + this(); + setType(SSHMessageType.ERROR); + setData(message); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHOutputResponse.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHOutputResponse.java new file mode 100644 index 00000000..7a51e020 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHOutputResponse.java @@ -0,0 +1,34 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.response; + +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * SSH终端输出响应(Framework层) + * + * 后端发送格式: + * { + * "type": "output", + * "data": { + * "response": { + * "type": "output", + * "data": "terminal output text" + * } + * } + * } + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SSHOutputResponse extends SSHBaseResponse { + + public SSHOutputResponse() { + setType(SSHMessageType.OUTPUT); + } + + public SSHOutputResponse(String data) { + this(); + setType(SSHMessageType.OUTPUT); + setData(data); + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHStatusResponse.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHStatusResponse.java new file mode 100644 index 00000000..92804a61 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/ssh/websocket/response/SSHStatusResponse.java @@ -0,0 +1,41 @@ +package com.qqchen.deploy.backend.framework.ssh.websocket.response; + +import com.qqchen.deploy.backend.framework.enums.SSHMessageType; +import com.qqchen.deploy.backend.framework.enums.SSHStatusEnum; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * SSH连接状态响应(Framework层) + * + * 后端发送格式: + * { + * "type": "status", + * "data": { + * "response": { + * "type": "status", + * "data": "connected" + * } + * } + * } + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SSHStatusResponse extends SSHBaseResponse { + + public SSHStatusResponse() { + setType(SSHMessageType.STATUS); + } + + public SSHStatusResponse(SSHStatusEnum status) { + this(); + setType(SSHMessageType.STATUS); + setData(status.name().toLowerCase()); + } + + public SSHStatusResponse(String status) { + this(); + setType(SSHMessageType.STATUS); + setData(status); + } +}