Initial commit: DataTool backend, frontend and Docker

This commit is contained in:
liu
2026-01-31 00:51:14 +08:00
commit 59bb8e16f5
69 changed files with 9449 additions and 0 deletions

68
backend/pom.xml Normal file
View 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>

View 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);
}
}

View File

@@ -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.htmlVue 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");
}
});
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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("\"", "\\\"");
}
}

View File

@@ -0,0 +1,198 @@
package com.datatool.message;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 统一消息模型,对应文档中的 Message。
*
* 支持类型:
* - TEXT / IMAGE使用 content
* - FILEfileId + fileName + fileSize
* - CHUNKfileId + chunkIndex + totalChunks + chunkSize
* - SYSTEMsystemCode + data
*/
public class MessagePayload {
@NotBlank
private String roomCode;
@NotNull
private MessageType type;
// 发送方标识(可选)
private String senderId;
// 发送方昵称(可选,用于前端展示)
private String senderName;
// 文本内容或图片 Base64 / URL视前端实现而定
private String content;
// 文本分片TEXT是否为分片、重组用 messageIddoc05
private Boolean isChunk;
private String messageId;
// 文件/分片相关doc06FILE 含 mimeTypeCHUNK 含 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;
}
}

View File

@@ -0,0 +1,20 @@
package com.datatool.message;
/**
* 消息类型枚举。
*
* 对应文档中的几类传输数据:
* - TEXT文本
* - FILE文件整体文件级别状态
* - IMAGE图片可视为特殊的文件/内容)
* - SYSTEM系统消息用户加入/离开、重连等)
* - CHUNK文件分片
*/
public enum MessageType {
TEXT,
FILE,
IMAGE,
SYSTEM,
CHUNK
}

View 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;
/**
* 房间模型。
*
* - roomCode6 位数字房间号
* - 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());
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}
}

View 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);
}
/**
* 文件分片透明转发到房间主 topicSimpleBroker 不支持通配符订阅,统一走主 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);
}
}

View File

@@ -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);
}
}

View 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 小时