Initial commit: DataTool backend, frontend and Docker
This commit is contained in:
68
backend/pom.xml
Normal file
68
backend/pom.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.datatool</groupId>
|
||||
<artifactId>datatool-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>DataTool Backend</name>
|
||||
<description>DataTool WebSocket backend for room-based data transfer</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
15
backend/src/main/java/com/datatool/DataToolApplication.java
Normal file
15
backend/src/main/java/com/datatool/DataToolApplication.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.datatool;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class DataToolApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DataToolApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.datatool.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* SPA 静态资源:将 dist 放在 classpath:/static/ 后,未匹配到文件时回退到 index.html(Vue Router history 模式)。
|
||||
*/
|
||||
@Configuration
|
||||
public class SpaResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource resource = location.createRelative(resourcePath);
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return resource;
|
||||
}
|
||||
return location.createRelative("index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.datatool.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 文件传输配置:上传目录、单文件大小上限、房间过期时间。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "datatool.transfer")
|
||||
public class TransferProperties {
|
||||
|
||||
private String uploadDir = "./data/uploads";
|
||||
private long maxFileSize = 104_857_600L; // 100MB
|
||||
private int roomExpireHours = 24;
|
||||
/** 定时过期清理间隔(毫秒),默认 1 小时。 */
|
||||
private long cleanupIntervalMs = 3600000L;
|
||||
|
||||
public String getUploadDir() {
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
public void setUploadDir(String uploadDir) {
|
||||
this.uploadDir = uploadDir;
|
||||
}
|
||||
|
||||
public long getMaxFileSize() {
|
||||
return maxFileSize;
|
||||
}
|
||||
|
||||
public void setMaxFileSize(long maxFileSize) {
|
||||
this.maxFileSize = maxFileSize;
|
||||
}
|
||||
|
||||
public int getRoomExpireHours() {
|
||||
return roomExpireHours;
|
||||
}
|
||||
|
||||
public void setRoomExpireHours(int roomExpireHours) {
|
||||
this.roomExpireHours = roomExpireHours;
|
||||
}
|
||||
|
||||
public long getCleanupIntervalMs() {
|
||||
return cleanupIntervalMs;
|
||||
}
|
||||
|
||||
public void setCleanupIntervalMs(long cleanupIntervalMs) {
|
||||
this.cleanupIntervalMs = cleanupIntervalMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.datatool.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
|
||||
|
||||
/**
|
||||
* WebSocket / STOMP 基础配置。
|
||||
*
|
||||
* - STOMP 端点:/ws(支持 SockJS)
|
||||
* - 应用前缀:/app
|
||||
* - 广播前缀:/topic
|
||||
*
|
||||
* 与文档中约定的路径保持一致:
|
||||
* - 客户端发送:/app/room/{roomCode}/join
|
||||
* - 客户端发送:/app/room/{roomCode}/message
|
||||
* - 客户端发送:/app/room/{roomCode}/file/chunk
|
||||
* - 服务端广播:/topic/room/{roomCode}[/**]
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
/** 64KB 分片 base64 后约 87KB,默认 64KB 限制会导致消息被拒、连接断开。设为 1MB 以支持文件分片。 */
|
||||
private static final int MESSAGE_SIZE_LIMIT = 1024 * 1024;
|
||||
|
||||
@Override
|
||||
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
|
||||
registration.setMessageSizeLimit(MESSAGE_SIZE_LIMIT);
|
||||
registration.setSendBufferSizeLimit(MESSAGE_SIZE_LIMIT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// 客户端订阅前缀,例如 /topic/room/{roomCode}
|
||||
config.enableSimpleBroker("/topic");
|
||||
// 客户端发送消息前缀,例如 /app/room/{roomCode}/message
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// STOMP 端点,前端通过 SockJS/WebSocket 连接到此路径
|
||||
registry.addEndpoint("/ws")
|
||||
// 生产环境建议改为具体域名或从配置读取
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.datatool.controller;
|
||||
|
||||
import com.datatool.room.RoomService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 房间相关 REST 接口。
|
||||
*
|
||||
* - POST /api/room/create:创建房间,返回 6 位房间号
|
||||
* - GET /api/room/my-ip:获取当前请求的客户端 IP,用于作为默认昵称
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/room")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class RoomController {
|
||||
|
||||
private final RoomService roomService;
|
||||
|
||||
public RoomController(RoomService roomService) {
|
||||
this.roomService = roomService;
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
public Map<String, String> createRoom() {
|
||||
String roomCode = roomService.createRoom();
|
||||
return Map.of("roomCode", roomCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的客户端真实 IP。
|
||||
* 优先级:X-Real-IP → X-Forwarded-For(取第一个)→ getRemoteAddr()。
|
||||
* 若结果为回环地址(127.0.0.1、::1 等),返回「本机」便于展示。
|
||||
*/
|
||||
@GetMapping("/my-ip")
|
||||
public Map<String, String> getMyIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Real-IP");
|
||||
if (ip == null || ip.isBlank()) {
|
||||
ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip != null && !ip.isBlank()) {
|
||||
int comma = ip.indexOf(',');
|
||||
ip = comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
|
||||
}
|
||||
}
|
||||
if (ip == null || ip.isBlank()) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip == null) {
|
||||
ip = "";
|
||||
}
|
||||
// IPv4-mapped IPv6(如 ::ffff:192.168.1.1)只保留 IPv4 部分便于展示
|
||||
ip = normalizeIp(ip);
|
||||
// 回环地址统一显示为「本机」
|
||||
if (isLoopback(ip)) {
|
||||
ip = "本机";
|
||||
}
|
||||
return Map.of("ip", ip);
|
||||
}
|
||||
|
||||
/** 去掉 ::ffff: 前缀,便于显示纯 IPv4 */
|
||||
private static String normalizeIp(String ip) {
|
||||
if (ip == null) return "";
|
||||
String s = ip.trim();
|
||||
if (s.toLowerCase().startsWith("::ffff:")) {
|
||||
return s.substring(7).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static boolean isLoopback(String ip) {
|
||||
if (ip == null || ip.isBlank()) return true;
|
||||
String normalized = ip.trim().toLowerCase();
|
||||
return "127.0.0.1".equals(normalized)
|
||||
|| "0:0:0:0:0:0:0:1".equals(normalized)
|
||||
|| "::1".equals(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.datatool.controller;
|
||||
|
||||
import com.datatool.config.TransferProperties;
|
||||
import com.datatool.service.UploadCleanupService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 房间内文件上传/下载 REST 接口。
|
||||
* 大文件走 HTTP,上传落盘后仅通过 WebSocket 广播元数据,下载从服务器目录流式返回。
|
||||
*
|
||||
* - POST /api/room/{roomCode}/file/upload
|
||||
* - GET /api/room/{roomCode}/file/{fileId}
|
||||
* - DELETE /api/room/{roomCode}/files (删除该房间下全部上传文件)
|
||||
* - DELETE /api/room/{roomCode}/file/{fileId} (删除单个文件及 .meta)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/room")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class RoomFileController {
|
||||
|
||||
private final TransferProperties transferProperties;
|
||||
private final UploadCleanupService uploadCleanupService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RoomFileController(TransferProperties transferProperties,
|
||||
UploadCleanupService uploadCleanupService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.transferProperties = transferProperties;
|
||||
this.uploadCleanupService = uploadCleanupService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private Path resolveUploadRoot() {
|
||||
return uploadCleanupService.resolveUploadRoot();
|
||||
}
|
||||
|
||||
@PostMapping("/{roomCode}/file/upload")
|
||||
public Map<String, Object> upload(
|
||||
@PathVariable String roomCode,
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("文件不能为空");
|
||||
}
|
||||
if (file.getSize() > transferProperties.getMaxFileSize()) {
|
||||
throw new IllegalArgumentException("文件大小超过限制");
|
||||
}
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
throw new IllegalArgumentException("房间号无效");
|
||||
}
|
||||
|
||||
String fileId = "f_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
Path uploadRoot = resolveUploadRoot();
|
||||
Path baseDir = uploadRoot.resolve(roomCode);
|
||||
Files.createDirectories(baseDir);
|
||||
Path filePath = baseDir.resolve(fileId);
|
||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
Files.copy(in, filePath);
|
||||
}
|
||||
|
||||
Map<String, String> meta = Map.of(
|
||||
"fileName", sanitizeFileName(file.getOriginalFilename() != null ? file.getOriginalFilename() : "download"),
|
||||
"mimeType", file.getContentType() != null ? file.getContentType() : "application/octet-stream"
|
||||
);
|
||||
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
||||
|
||||
return Map.<String, Object>of(
|
||||
"fileId", fileId,
|
||||
"fileName", meta.get("fileName"),
|
||||
"fileSize", file.getSize(),
|
||||
"mimeType", meta.get("mimeType")
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{roomCode}/file/{fileId}")
|
||||
public ResponseEntity<Resource> download(
|
||||
@PathVariable String roomCode,
|
||||
@PathVariable String fileId) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || !isSafeFileId(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Path uploadRoot = resolveUploadRoot();
|
||||
Path baseDir = uploadRoot.resolve(roomCode);
|
||||
Path filePath = baseDir.resolve(fileId);
|
||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||
|
||||
if (!Files.isRegularFile(filePath)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String fileName = fileId;
|
||||
String mimeType = "application/octet-stream";
|
||||
if (Files.isRegularFile(metaPath)) {
|
||||
String json = Files.readString(metaPath);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> meta = objectMapper.readValue(json, Map.class);
|
||||
fileName = meta.getOrDefault("fileName", fileId);
|
||||
mimeType = meta.getOrDefault("mimeType", mimeType);
|
||||
}
|
||||
|
||||
String contentDisposition = "attachment; filename=\"" + escapeForQuotedString(fileName) + "\"";
|
||||
|
||||
Resource resource = new FileSystemResource(filePath);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(mimeType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除该房间下全部上传文件(仅删磁盘目录,不删内存房间)。
|
||||
*/
|
||||
@DeleteMapping("/{roomCode}/files")
|
||||
public ResponseEntity<Void> deleteRoomFiles(@PathVariable String roomCode) {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
boolean deleted = uploadCleanupService.deleteRoomFolder(roomCode);
|
||||
return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个文件及其 .meta。
|
||||
*/
|
||||
@DeleteMapping("/{roomCode}/file/{fileId}")
|
||||
public ResponseEntity<Void> deleteFile(
|
||||
@PathVariable String roomCode,
|
||||
@PathVariable String fileId) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6 || !isSafeFileId(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Path uploadRoot = resolveUploadRoot();
|
||||
Path baseDir = uploadRoot.resolve(roomCode);
|
||||
Path filePath = baseDir.resolve(fileId);
|
||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||
if (!Files.isRegularFile(filePath)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Files.deleteIfExists(filePath);
|
||||
Files.deleteIfExists(metaPath);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static boolean isSafeRoomCode(String roomCode) {
|
||||
return roomCode != null && roomCode.matches("[0-9]+");
|
||||
}
|
||||
|
||||
private static boolean isSafeFileId(String fileId) {
|
||||
return fileId != null && fileId.matches("f_[0-9]+_[a-zA-Z0-9]{12}");
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String name) {
|
||||
if (name == null) return "download";
|
||||
String s = name.replaceAll("[\\\\/<>:\"|?*]", "_");
|
||||
return s.isEmpty() ? "download" : s;
|
||||
}
|
||||
|
||||
private static String escapeForQuotedString(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
198
backend/src/main/java/com/datatool/message/MessagePayload.java
Normal file
198
backend/src/main/java/com/datatool/message/MessagePayload.java
Normal file
@@ -0,0 +1,198 @@
|
||||
package com.datatool.message;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 统一消息模型,对应文档中的 Message。
|
||||
*
|
||||
* 支持类型:
|
||||
* - TEXT / IMAGE:使用 content
|
||||
* - FILE:fileId + fileName + fileSize
|
||||
* - CHUNK:fileId + chunkIndex + totalChunks + chunkSize
|
||||
* - SYSTEM:systemCode + data
|
||||
*/
|
||||
public class MessagePayload {
|
||||
|
||||
@NotBlank
|
||||
private String roomCode;
|
||||
|
||||
@NotNull
|
||||
private MessageType type;
|
||||
|
||||
// 发送方标识(可选)
|
||||
private String senderId;
|
||||
|
||||
// 发送方昵称(可选,用于前端展示)
|
||||
private String senderName;
|
||||
|
||||
// 文本内容或图片 Base64 / URL(视前端实现而定)
|
||||
private String content;
|
||||
|
||||
// 文本分片(TEXT):是否为分片、重组用 messageId(doc05)
|
||||
private Boolean isChunk;
|
||||
private String messageId;
|
||||
|
||||
// 文件/分片相关(doc06:FILE 含 mimeType,CHUNK 含 content base64)
|
||||
private String fileId;
|
||||
private String fileName;
|
||||
private Long fileSize;
|
||||
private String mimeType;
|
||||
/** IMAGE 类型小图直发:base64 图片数据(doc07) */
|
||||
private String imageData;
|
||||
private Integer chunkIndex;
|
||||
private Integer totalChunks;
|
||||
private Integer chunkSize;
|
||||
|
||||
// 系统消息相关
|
||||
private String systemCode;
|
||||
private Object data;
|
||||
|
||||
// 由服务端统一补齐
|
||||
private Long timestamp;
|
||||
|
||||
public String getRoomCode() {
|
||||
return roomCode;
|
||||
}
|
||||
|
||||
public void setRoomCode(String roomCode) {
|
||||
this.roomCode = roomCode;
|
||||
}
|
||||
|
||||
public MessageType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(MessageType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getSenderId() {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
public void setSenderId(String senderId) {
|
||||
this.senderId = senderId;
|
||||
}
|
||||
|
||||
public String getSenderName() {
|
||||
return senderName;
|
||||
}
|
||||
|
||||
public void setSenderName(String senderName) {
|
||||
this.senderName = senderName;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Boolean getIsChunk() {
|
||||
return isChunk;
|
||||
}
|
||||
|
||||
public void setIsChunk(Boolean isChunk) {
|
||||
this.isChunk = isChunk;
|
||||
}
|
||||
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public String getFileId() {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
public void setFileId(String fileId) {
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public Long getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public void setFileSize(Long fileSize) {
|
||||
this.fileSize = fileSize;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public void setMimeType(String mimeType) {
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
public String getImageData() {
|
||||
return imageData;
|
||||
}
|
||||
|
||||
public void setImageData(String imageData) {
|
||||
this.imageData = imageData;
|
||||
}
|
||||
|
||||
public Integer getChunkIndex() {
|
||||
return chunkIndex;
|
||||
}
|
||||
|
||||
public void setChunkIndex(Integer chunkIndex) {
|
||||
this.chunkIndex = chunkIndex;
|
||||
}
|
||||
|
||||
public Integer getTotalChunks() {
|
||||
return totalChunks;
|
||||
}
|
||||
|
||||
public void setTotalChunks(Integer totalChunks) {
|
||||
this.totalChunks = totalChunks;
|
||||
}
|
||||
|
||||
public Integer getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
public void setChunkSize(Integer chunkSize) {
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
public String getSystemCode() {
|
||||
return systemCode;
|
||||
}
|
||||
|
||||
public void setSystemCode(String systemCode) {
|
||||
this.systemCode = systemCode;
|
||||
}
|
||||
|
||||
public Object getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(Object data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public Long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(Long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/src/main/java/com/datatool/message/MessageType.java
Normal file
20
backend/src/main/java/com/datatool/message/MessageType.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.datatool.message;
|
||||
|
||||
/**
|
||||
* 消息类型枚举。
|
||||
*
|
||||
* 对应文档中的几类传输数据:
|
||||
* - TEXT:文本
|
||||
* - FILE:文件(整体文件级别状态)
|
||||
* - IMAGE:图片(可视为特殊的文件/内容)
|
||||
* - SYSTEM:系统消息(用户加入/离开、重连等)
|
||||
* - CHUNK:文件分片
|
||||
*/
|
||||
public enum MessageType {
|
||||
TEXT,
|
||||
FILE,
|
||||
IMAGE,
|
||||
SYSTEM,
|
||||
CHUNK
|
||||
}
|
||||
|
||||
68
backend/src/main/java/com/datatool/room/Room.java
Normal file
68
backend/src/main/java/com/datatool/room/Room.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.datatool.room;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 房间模型。
|
||||
*
|
||||
* - roomCode:6 位数字房间号
|
||||
* - sessions:当前在线会话
|
||||
* - createdAt / expiresAt:为后续 NFR(过期回收)预留
|
||||
*/
|
||||
public class Room {
|
||||
|
||||
private String roomCode;
|
||||
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
|
||||
private final Instant createdAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
public Room(String roomCode) {
|
||||
this.roomCode = roomCode;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public String getRoomCode() {
|
||||
return roomCode;
|
||||
}
|
||||
|
||||
public void setRoomCode(String roomCode) {
|
||||
this.roomCode = roomCode;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public void addSession(SessionInfo sessionInfo) {
|
||||
if (sessionInfo != null && sessionInfo.getSessionId() != null) {
|
||||
sessions.put(sessionInfo.getSessionId(), sessionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeSession(String sessionId) {
|
||||
if (sessionId != null) {
|
||||
sessions.remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public SessionInfo getSession(String sessionId) {
|
||||
return sessionId == null ? null : sessions.get(sessionId);
|
||||
}
|
||||
|
||||
public Collection<SessionInfo> getSessions() {
|
||||
return Collections.unmodifiableCollection(sessions.values());
|
||||
}
|
||||
}
|
||||
|
||||
118
backend/src/main/java/com/datatool/room/RoomService.java
Normal file
118
backend/src/main/java/com/datatool/room/RoomService.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.datatool.room;
|
||||
|
||||
import com.datatool.service.UploadCleanupService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 房间与会话管理服务。
|
||||
*
|
||||
* - 创建房间:生成 6 位数字房间号
|
||||
* - 负责加入/离开房间
|
||||
* - 维护房间内在线用户列表
|
||||
* - 预留房间过期清理能力
|
||||
*/
|
||||
@Service
|
||||
public class RoomService {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final UploadCleanupService uploadCleanupService;
|
||||
|
||||
public RoomService(SessionRegistry sessionRegistry, UploadCleanupService uploadCleanupService) {
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.uploadCleanupService = uploadCleanupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间:生成唯一 6 位数字房间号并创建房间。
|
||||
*/
|
||||
public String createRoom() {
|
||||
String roomCode;
|
||||
do {
|
||||
roomCode = String.valueOf(100000 + RANDOM.nextInt(900000));
|
||||
} while (rooms.containsKey(roomCode));
|
||||
rooms.put(roomCode, new Room(roomCode));
|
||||
return roomCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间内昵称唯一化:若已有同名则追加 -2、-3 等,便于同 IP 多端区分。
|
||||
*/
|
||||
public String ensureUniqueNickname(String roomCode, String nickname) {
|
||||
if (nickname == null || nickname.isBlank()) return nickname;
|
||||
Room room = rooms.get(roomCode);
|
||||
if (room == null) return nickname;
|
||||
Set<String> used = room.getSessions().stream()
|
||||
.map(SessionInfo::getNickname)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (!used.contains(nickname)) return nickname;
|
||||
for (int i = 2; ; i++) {
|
||||
String candidate = nickname + "-" + i;
|
||||
if (!used.contains(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户加入房间。
|
||||
*/
|
||||
public Room joinRoom(String roomCode, String sessionId, String userId, String nickname) {
|
||||
Room room = rooms.computeIfAbsent(roomCode, Room::new);
|
||||
String uniqueNickname = ensureUniqueNickname(roomCode, nickname);
|
||||
SessionInfo sessionInfo = new SessionInfo(sessionId, userId, uniqueNickname, Instant.now());
|
||||
room.addSession(sessionInfo);
|
||||
sessionRegistry.register(sessionId, roomCode);
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话离开所在房间。
|
||||
*/
|
||||
public Room leaveRoom(String sessionId) {
|
||||
String roomCode = sessionRegistry.getRoomCode(sessionId);
|
||||
if (roomCode == null) {
|
||||
return null;
|
||||
}
|
||||
Room room = rooms.get(roomCode);
|
||||
if (room != null) {
|
||||
room.removeSession(sessionId);
|
||||
if (room.getSessions().isEmpty()) {
|
||||
rooms.remove(roomCode);
|
||||
uploadCleanupService.deleteRoomFolder(roomCode);
|
||||
}
|
||||
}
|
||||
sessionRegistry.remove(sessionId);
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存中是否仍存在该房间(用于定时清理判断:仅清理“已不在内存”且过期的目录)。
|
||||
*/
|
||||
public boolean hasRoom(String roomCode) {
|
||||
return roomCode != null && rooms.containsKey(roomCode);
|
||||
}
|
||||
|
||||
public Collection<SessionInfo> getUsers(String roomCode) {
|
||||
Room room = rooms.get(roomCode);
|
||||
return room == null ? java.util.List.of() : room.getSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间内指定会话的信息(用于离开时广播昵称)。
|
||||
*/
|
||||
public SessionInfo getSessionInfo(String roomCode, String sessionId) {
|
||||
Room room = rooms.get(roomCode);
|
||||
return room == null ? null : room.getSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
61
backend/src/main/java/com/datatool/room/SessionInfo.java
Normal file
61
backend/src/main/java/com/datatool/room/SessionInfo.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.datatool.room;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* WebSocket 会话信息。
|
||||
*
|
||||
* - sessionId:底层 WebSocket/STOMP 会话 ID
|
||||
* - userId / nickname:前端可选传入的用户标识
|
||||
* - joinedAt:加入房间时间
|
||||
*/
|
||||
public class SessionInfo {
|
||||
|
||||
private String sessionId;
|
||||
private String userId;
|
||||
private String nickname;
|
||||
private Instant joinedAt;
|
||||
|
||||
public SessionInfo() {
|
||||
}
|
||||
|
||||
public SessionInfo(String sessionId, String userId, String nickname, Instant joinedAt) {
|
||||
this.sessionId = sessionId;
|
||||
this.userId = userId;
|
||||
this.nickname = nickname;
|
||||
this.joinedAt = joinedAt;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public Instant getJoinedAt() {
|
||||
return joinedAt;
|
||||
}
|
||||
|
||||
public void setJoinedAt(Instant joinedAt) {
|
||||
this.joinedAt = joinedAt;
|
||||
}
|
||||
}
|
||||
|
||||
33
backend/src/main/java/com/datatool/room/SessionRegistry.java
Normal file
33
backend/src/main/java/com/datatool/room/SessionRegistry.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.datatool.room;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 维护 sessionId 与 roomCode 的映射关系,
|
||||
* 方便在断线或显式离开时进行清理。
|
||||
*/
|
||||
@Component
|
||||
public class SessionRegistry {
|
||||
|
||||
private final Map<String, String> sessionRoomMap = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(String sessionId, String roomCode) {
|
||||
if (sessionId != null && roomCode != null) {
|
||||
sessionRoomMap.put(sessionId, roomCode);
|
||||
}
|
||||
}
|
||||
|
||||
public String getRoomCode(String sessionId) {
|
||||
return sessionId == null ? null : sessionRoomMap.get(sessionId);
|
||||
}
|
||||
|
||||
public void remove(String sessionId) {
|
||||
if (sessionId != null) {
|
||||
sessionRoomMap.remove(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.datatool.service;
|
||||
|
||||
import com.datatool.config.TransferProperties;
|
||||
import com.datatool.room.RoomService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 定时过期清理:扫描上传根目录下各房间目录,对“内存已无房间且目录年龄超过 room-expire-hours”的进行删除。
|
||||
*/
|
||||
@Component
|
||||
public class ScheduledUploadCleanup {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ScheduledUploadCleanup.class);
|
||||
|
||||
private static final String ROOM_CODE_PATTERN = "[0-9]{6}";
|
||||
|
||||
private final UploadCleanupService uploadCleanupService;
|
||||
private final RoomService roomService;
|
||||
private final TransferProperties transferProperties;
|
||||
|
||||
public ScheduledUploadCleanup(UploadCleanupService uploadCleanupService,
|
||||
RoomService roomService,
|
||||
TransferProperties transferProperties) {
|
||||
this.uploadCleanupService = uploadCleanupService;
|
||||
this.roomService = roomService;
|
||||
this.transferProperties = transferProperties;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRateString = "${datatool.transfer.cleanup-interval-ms:3600000}")
|
||||
public void cleanupExpiredRoomFolders() {
|
||||
Path root = uploadCleanupService.resolveUploadRoot();
|
||||
if (!Files.isDirectory(root)) {
|
||||
return;
|
||||
}
|
||||
int expireHours = transferProperties.getRoomExpireHours();
|
||||
long expireSeconds = expireHours * 3600L;
|
||||
long nowSeconds = Instant.now().getEpochSecond();
|
||||
|
||||
try (Stream<Path> list = Files.list(root)) {
|
||||
list.filter(Files::isDirectory)
|
||||
.map(Path::getFileName)
|
||||
.map(Path::toString)
|
||||
.filter(roomCode -> roomCode.matches(ROOM_CODE_PATTERN))
|
||||
.forEach(roomCode -> {
|
||||
if (roomService.hasRoom(roomCode)) {
|
||||
return;
|
||||
}
|
||||
Path roomDir = root.resolve(roomCode);
|
||||
try {
|
||||
long lastModified = Files.getLastModifiedTime(roomDir).toInstant().getEpochSecond();
|
||||
if (nowSeconds - lastModified >= expireSeconds) {
|
||||
uploadCleanupService.deleteRoomFolder(roomCode);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("[ScheduledUploadCleanup] 无法读取目录时间 roomCode={} error={}", roomCode, e.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("[ScheduledUploadCleanup] 列出上传目录失败 error={}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.datatool.service;
|
||||
|
||||
import com.datatool.config.TransferProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 上传目录清理:按房间删除磁盘目录,供“房间空置即删”、定时过期清理、删除 API”使用。
|
||||
*/
|
||||
@Service
|
||||
public class UploadCleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UploadCleanupService.class);
|
||||
|
||||
/** 房间号格式:6 位数字,防止路径穿越。 */
|
||||
private static final String ROOM_CODE_PATTERN = "[0-9]{6}";
|
||||
|
||||
private final TransferProperties transferProperties;
|
||||
|
||||
public UploadCleanupService(TransferProperties transferProperties) {
|
||||
this.transferProperties = transferProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析上传根目录为绝对路径,兼容 Linux 与 Windows。
|
||||
* 相对路径基于进程工作目录(user.dir)解析。
|
||||
*/
|
||||
public Path resolveUploadRoot() {
|
||||
String dir = transferProperties.getUploadDir();
|
||||
if (dir == null || dir.isBlank()) {
|
||||
dir = "./data/uploads";
|
||||
}
|
||||
Path p = Paths.get(dir);
|
||||
if (!p.isAbsolute()) {
|
||||
p = Paths.get(System.getProperty("user.dir", ".")).resolve(p);
|
||||
}
|
||||
return p.normalize().toAbsolutePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定房间的上传目录(含其下所有文件及 .meta)。
|
||||
* 仅当 roomCode 符合 6 位数字时执行,否则不删并打日志。
|
||||
*
|
||||
* @param roomCode 6 位数字房间号
|
||||
* @return 是否执行了删除(目录存在且已删为 true;未通过校验或目录不存在为 false)
|
||||
*/
|
||||
public boolean deleteRoomFolder(String roomCode) {
|
||||
if (roomCode == null || !roomCode.matches(ROOM_CODE_PATTERN)) {
|
||||
log.warn("[UploadCleanup] 跳过删除:房间号格式无效 roomCode={}", roomCode);
|
||||
return false;
|
||||
}
|
||||
Path root = resolveUploadRoot();
|
||||
Path roomDir = root.resolve(roomCode);
|
||||
if (!Files.isDirectory(roomDir)) {
|
||||
log.debug("[UploadCleanup] 房间目录不存在,跳过 roomCode={}", roomCode);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
try (Stream<Path> walk = Files.walk(roomDir)) {
|
||||
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try {
|
||||
Files.delete(p);
|
||||
} catch (IOException e) {
|
||||
log.warn("[UploadCleanup] 删除项失败 path={} error={}", p, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
log.info("[UploadCleanup] 已删除房间上传目录 roomCode={}", roomCode);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.error("[UploadCleanup] 删除房间目录失败 roomCode={} error={}", roomCode, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
backend/src/main/java/com/datatool/ws/RoomWsController.java
Normal file
126
backend/src/main/java/com/datatool/ws/RoomWsController.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package com.datatool.ws;
|
||||
|
||||
import com.datatool.message.MessagePayload;
|
||||
import com.datatool.message.MessageType;
|
||||
import com.datatool.room.RoomService;
|
||||
import com.datatool.room.SessionInfo;
|
||||
import com.datatool.room.SessionRegistry;
|
||||
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 房间相关 WebSocket/STOMP 端点。
|
||||
*
|
||||
* 路径与文档保持一致:
|
||||
* - 加入房间:/app/room/{roomCode}/join
|
||||
* - 发送消息:/app/room/{roomCode}/message
|
||||
* - 文件分片:/app/room/{roomCode}/file/chunk
|
||||
*
|
||||
* 广播路径:
|
||||
* - 房间广播:/topic/room/{roomCode}
|
||||
* - 文件分片:/topic/room/{roomCode}/file/{fileId}
|
||||
*/
|
||||
@Controller
|
||||
public class RoomWsController {
|
||||
|
||||
private final RoomService roomService;
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public RoomWsController(RoomService roomService,
|
||||
SessionRegistry sessionRegistry,
|
||||
SimpMessagingTemplate messagingTemplate) {
|
||||
this.roomService = roomService;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端加入房间后,服务端更新房间用户列表并广播 SYSTEM(USER_JOIN + userList)。
|
||||
*/
|
||||
@MessageMapping("/room/{roomCode}/join")
|
||||
public void joinRoom(@DestinationVariable String roomCode,
|
||||
MessagePayload payload,
|
||||
SimpMessageHeaderAccessor headerAccessor) {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
String userId = payload.getSenderId();
|
||||
String nickname = payload.getContent(); // 暂将 content 视为昵称占位,后续可单独扩展字段
|
||||
|
||||
roomService.joinRoom(roomCode, sessionId, userId, nickname);
|
||||
|
||||
String joinMessage = (nickname != null && !nickname.isEmpty()) ? nickname + " 加入房间" : "有用户加入房间";
|
||||
Map<String, Object> joinData = new HashMap<>();
|
||||
joinData.put("message", joinMessage);
|
||||
joinData.put("userList", roomService.getUsers(roomCode));
|
||||
|
||||
MessagePayload system = new MessagePayload();
|
||||
system.setRoomCode(roomCode);
|
||||
system.setType(MessageType.SYSTEM);
|
||||
system.setSystemCode("USER_JOIN");
|
||||
system.setData(joinData);
|
||||
system.setTimestamp(Instant.now().toEpochMilli());
|
||||
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, system);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端主动离开房间:移除会话、若房间空则移除房间,并广播 SYSTEM(USER_LEAVE + userList)。
|
||||
*/
|
||||
@MessageMapping("/room/{roomCode}/leave")
|
||||
public void leaveRoom(@DestinationVariable String roomCode,
|
||||
SimpMessageHeaderAccessor headerAccessor) {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
String currentRoomCode = sessionRegistry.getRoomCode(sessionId);
|
||||
if (currentRoomCode == null || !currentRoomCode.equals(roomCode)) {
|
||||
return;
|
||||
}
|
||||
SessionInfo leavingSession = roomService.getSessionInfo(roomCode, sessionId);
|
||||
String leaveNickname = leavingSession != null && leavingSession.getNickname() != null
|
||||
? leavingSession.getNickname() : "某用户";
|
||||
roomService.leaveRoom(sessionId);
|
||||
MessagePayload system = new MessagePayload();
|
||||
system.setRoomCode(roomCode);
|
||||
system.setType(MessageType.SYSTEM);
|
||||
system.setSystemCode("USER_LEAVE");
|
||||
system.setTimestamp(Instant.now().toEpochMilli());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("message", leaveNickname != null ? leaveNickname + " 离开房间" : "有用户离开房间");
|
||||
data.put("userList", roomService.getUsers(roomCode));
|
||||
system.setData(data);
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, system);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本/文件/图片/系统消息统一入口,服务端补齐时间戳并广播。
|
||||
*/
|
||||
@MessageMapping("/room/{roomCode}/message")
|
||||
public void sendMessage(@DestinationVariable String roomCode,
|
||||
MessagePayload payload) {
|
||||
payload.setRoomCode(roomCode);
|
||||
payload.setTimestamp(Instant.now().toEpochMilli());
|
||||
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分片透明转发到房间主 topic(SimpleBroker 不支持通配符订阅,统一走主 topic)。
|
||||
*/
|
||||
@MessageMapping("/room/{roomCode}/file/chunk")
|
||||
public void sendFileChunk(@DestinationVariable String roomCode,
|
||||
MessagePayload payload) {
|
||||
payload.setRoomCode(roomCode);
|
||||
payload.setType(MessageType.CHUNK);
|
||||
payload.setTimestamp(Instant.now().toEpochMilli());
|
||||
|
||||
// 统一发到房间主 topic,前端按 type=CHUNK 处理
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.datatool.ws;
|
||||
|
||||
import com.datatool.message.MessagePayload;
|
||||
import com.datatool.message.MessageType;
|
||||
import com.datatool.room.Room;
|
||||
import com.datatool.room.RoomService;
|
||||
import com.datatool.room.SessionInfo;
|
||||
import com.datatool.room.SessionRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* WebSocket 事件监听器。
|
||||
*
|
||||
* 处理断线事件:
|
||||
* - 当用户 WebSocket 断开时(关闭浏览器、网络中断等),自动触发 leave 并广播用户列表更新。
|
||||
* - 符合文档 09:断线处理 - 需要在服务端捕获 disconnect 事件,触发 leave 并广播列表更新。
|
||||
*/
|
||||
@Component
|
||||
public class WebSocketEventListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class);
|
||||
|
||||
private final RoomService roomService;
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public WebSocketEventListener(RoomService roomService,
|
||||
SessionRegistry sessionRegistry,
|
||||
SimpMessagingTemplate messagingTemplate) {
|
||||
this.roomService = roomService;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 断开连接事件。
|
||||
* 当用户断线时,自动将其从房间移除并广播用户列表更新。
|
||||
*/
|
||||
@EventListener
|
||||
public void handleSessionDisconnect(SessionDisconnectEvent event) {
|
||||
String sessionId = event.getSessionId();
|
||||
log.info("[WS] 会话断开: sessionId={}", sessionId);
|
||||
|
||||
// 获取该会话所在的房间
|
||||
String roomCode = sessionRegistry.getRoomCode(sessionId);
|
||||
if (roomCode == null) {
|
||||
log.debug("[WS] 断开的会话不在任何房间中: sessionId={}", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取离开用户的信息(用于广播消息)
|
||||
SessionInfo leavingSession = roomService.getSessionInfo(roomCode, sessionId);
|
||||
String leaveNickname = (leavingSession != null && leavingSession.getNickname() != null)
|
||||
? leavingSession.getNickname()
|
||||
: "某用户";
|
||||
|
||||
// 从房间移除该用户
|
||||
Room room = roomService.leaveRoom(sessionId);
|
||||
if (room == null) {
|
||||
log.warn("[WS] 移除会话失败,房间可能已不存在: roomCode={}, sessionId={}", roomCode, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[WS] 用户断线离开房间: roomCode={}, nickname={}, sessionId={}", roomCode, leaveNickname, sessionId);
|
||||
|
||||
// 广播 SYSTEM(USER_LEAVE + userList)
|
||||
MessagePayload system = new MessagePayload();
|
||||
system.setRoomCode(roomCode);
|
||||
system.setType(MessageType.SYSTEM);
|
||||
system.setSystemCode("USER_LEAVE");
|
||||
system.setTimestamp(Instant.now().toEpochMilli());
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("message", leaveNickname + " 离开房间");
|
||||
data.put("userList", roomService.getUsers(roomCode));
|
||||
system.setData(data);
|
||||
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, system);
|
||||
}
|
||||
}
|
||||
26
backend/src/main/resources/application.yml
Normal file
26
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
server:
|
||||
port: 8080
|
||||
# 允许其它设备直连后端(如不通过 Vite 代理、直接访问本机 IP:8080 时)
|
||||
address: 0.0.0.0
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: datatool-backend
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
max-request-size: 100MB
|
||||
|
||||
# WebSocket / STOMP 基础配置占位,后续可扩展
|
||||
datatool:
|
||||
websocket:
|
||||
endpoint: /ws
|
||||
allowed-origins:
|
||||
- "*"
|
||||
# 文件上传存储与限制(大文件走 HTTP 上传/下载,避免 WebSocket 断连)
|
||||
transfer:
|
||||
upload-dir: ./data/uploads
|
||||
max-file-size: 104857600 # 100MB
|
||||
room-expire-hours: 24
|
||||
cleanup-interval-ms: 3600000 # 定时过期清理间隔(毫秒),默认 1 小时
|
||||
|
||||
Reference in New Issue
Block a user