feat(room): 增加加入令牌及分块传输支持
- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌 - 新增房间加入令牌验证接口,加入时需提供房间号和令牌 - 前端HomeView新增加入令牌输入框及验证逻辑 - Clipboard工具增加写入API支持及复制按钮 - FileDropZone支持选择文件夹批量上传 - FileMessage和ImageMessage新增分片进度提示及失败重试功能 - API层新增分块上传及断点续传实现,支持大文件分片上传 - 文件上传存储时计算文件sha256,响应中返回该值 - 下载接口支持断点续传,优化大文件下载体验 - README新增加入令牌安全说明及压力测试使用示例 - 资源清理与配置优化,添加磁盘使用水位阈值控制
This commit is contained in:
@@ -15,6 +15,10 @@ public class TransferProperties {
|
||||
private int roomExpireHours = 24;
|
||||
/** 定时过期清理间隔(毫秒),默认 1 小时。 */
|
||||
private long cleanupIntervalMs = 3600000L;
|
||||
/** 磁盘使用率高水位(%):达到后触发按最旧目录回收。 */
|
||||
private int diskHighWatermarkPercent = 80;
|
||||
/** 磁盘回收目标水位(%):回收到低于该值即停止。 */
|
||||
private int diskTargetWatermarkPercent = 70;
|
||||
|
||||
public String getUploadDir() {
|
||||
return uploadDir;
|
||||
@@ -47,4 +51,20 @@ public class TransferProperties {
|
||||
public void setCleanupIntervalMs(long cleanupIntervalMs) {
|
||||
this.cleanupIntervalMs = cleanupIntervalMs;
|
||||
}
|
||||
|
||||
public int getDiskHighWatermarkPercent() {
|
||||
return diskHighWatermarkPercent;
|
||||
}
|
||||
|
||||
public void setDiskHighWatermarkPercent(int diskHighWatermarkPercent) {
|
||||
this.diskHighWatermarkPercent = diskHighWatermarkPercent;
|
||||
}
|
||||
|
||||
public int getDiskTargetWatermarkPercent() {
|
||||
return diskTargetWatermarkPercent;
|
||||
}
|
||||
|
||||
public void setDiskTargetWatermarkPercent(int diskTargetWatermarkPercent) {
|
||||
this.diskTargetWatermarkPercent = diskTargetWatermarkPercent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.datatool.controller;
|
||||
|
||||
import com.datatool.room.RoomService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -29,8 +31,21 @@ public class RoomController {
|
||||
|
||||
@PostMapping("/create")
|
||||
public Map<String, String> createRoom() {
|
||||
String roomCode = roomService.createRoom();
|
||||
return Map.of("roomCode", roomCode);
|
||||
RoomService.CreateRoomResult result = roomService.createRoom();
|
||||
return Map.of(
|
||||
"roomCode", result.roomCode(),
|
||||
"joinToken", result.joinToken()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/verify")
|
||||
public ResponseEntity<Map<String, Boolean>> verifyRoomAccess(
|
||||
@RequestParam String roomCode,
|
||||
@RequestParam String joinToken) {
|
||||
boolean ok = roomService.verifyJoinToken(roomCode, joinToken);
|
||||
return ok
|
||||
? ResponseEntity.ok(Map.of("ok", true))
|
||||
: ResponseEntity.status(403).body(Map.of("ok", false));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,20 +3,32 @@ package com.datatool.controller;
|
||||
import com.datatool.config.TransferProperties;
|
||||
import com.datatool.service.UploadCleanupService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 房间内文件上传/下载 REST 接口。
|
||||
@@ -32,6 +44,8 @@ import java.util.UUID;
|
||||
@CrossOrigin(origins = "*")
|
||||
public class RoomFileController {
|
||||
|
||||
private static final String UPLOAD_TMP_FOLDER = ".upload-tmp";
|
||||
|
||||
private final TransferProperties transferProperties;
|
||||
private final UploadCleanupService uploadCleanupService;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -70,27 +84,181 @@ public class RoomFileController {
|
||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
Files.copy(in, filePath);
|
||||
String sha256 = writeFileAndComputeSha256(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",
|
||||
"sha256", sha256
|
||||
);
|
||||
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
||||
|
||||
return Map.<String, Object>of(
|
||||
"fileId", fileId,
|
||||
"fileName", meta.get("fileName"),
|
||||
"fileSize", file.getSize(),
|
||||
"mimeType", meta.get("mimeType"),
|
||||
"sha256", sha256
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{roomCode}/file/upload/init", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Map<String, Object> initChunkUpload(
|
||||
@PathVariable String roomCode,
|
||||
@RequestBody InitUploadRequest request) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
throw new IllegalArgumentException("房间号无效");
|
||||
}
|
||||
if (request == null || request.totalChunks() == null || request.totalChunks() <= 0) {
|
||||
throw new IllegalArgumentException("分片参数无效");
|
||||
}
|
||||
if (request.fileSize() == null || request.fileSize() <= 0 || request.fileSize() > transferProperties.getMaxFileSize()) {
|
||||
throw new IllegalArgumentException("文件大小超过限制");
|
||||
}
|
||||
String uploadId = sanitizeUploadId(request.uploadId());
|
||||
if (uploadId.isBlank()) {
|
||||
throw new IllegalArgumentException("uploadId 无效");
|
||||
}
|
||||
|
||||
Path uploadDir = resolveTmpUploadDir(roomCode, uploadId);
|
||||
Files.createDirectories(uploadDir);
|
||||
Path infoPath = uploadDir.resolve("upload-info.json");
|
||||
|
||||
Map<String, Object> info = Map.of(
|
||||
"roomCode", roomCode,
|
||||
"fileName", sanitizeFileName(request.fileName()),
|
||||
"mimeType", request.mimeType() == null || request.mimeType().isBlank() ? "application/octet-stream" : request.mimeType(),
|
||||
"fileSize", request.fileSize(),
|
||||
"totalChunks", request.totalChunks(),
|
||||
"updatedAt", Instant.now().toEpochMilli()
|
||||
);
|
||||
Files.writeString(infoPath, objectMapper.writeValueAsString(info));
|
||||
|
||||
List<Integer> uploadedChunks = listUploadedChunks(uploadDir);
|
||||
return Map.of(
|
||||
"uploadId", uploadId,
|
||||
"uploadedChunks", uploadedChunks,
|
||||
"totalChunks", request.totalChunks()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{roomCode}/file/upload/chunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Map<String, Object> uploadChunk(
|
||||
@PathVariable String roomCode,
|
||||
@RequestParam("uploadId") String uploadId,
|
||||
@RequestParam("chunkIndex") Integer chunkIndex,
|
||||
@RequestParam("chunk") MultipartFile chunk) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
throw new IllegalArgumentException("房间号无效");
|
||||
}
|
||||
if (chunkIndex == null || chunkIndex < 0) {
|
||||
throw new IllegalArgumentException("chunkIndex 无效");
|
||||
}
|
||||
if (chunk == null || chunk.isEmpty()) {
|
||||
throw new IllegalArgumentException("分片不能为空");
|
||||
}
|
||||
String safeUploadId = sanitizeUploadId(uploadId);
|
||||
if (safeUploadId.isBlank()) {
|
||||
throw new IllegalArgumentException("uploadId 无效");
|
||||
}
|
||||
|
||||
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||
if (!Files.isDirectory(uploadDir)) {
|
||||
throw new IllegalArgumentException("上传会话不存在,请重新初始化");
|
||||
}
|
||||
Path chunkPath = uploadDir.resolve(chunkIndex + ".part");
|
||||
try (InputStream in = chunk.getInputStream()) {
|
||||
Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
return Map.of("ok", true, "chunkIndex", chunkIndex);
|
||||
}
|
||||
|
||||
@GetMapping(path = "/{roomCode}/file/upload/status")
|
||||
public Map<String, Object> getUploadStatus(
|
||||
@PathVariable String roomCode,
|
||||
@RequestParam("uploadId") String uploadId) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
return Map.of("uploadId", "", "uploadedChunks", List.of());
|
||||
}
|
||||
String safeUploadId = sanitizeUploadId(uploadId);
|
||||
if (safeUploadId.isBlank()) {
|
||||
return Map.of("uploadId", "", "uploadedChunks", List.of());
|
||||
}
|
||||
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||
if (!Files.isDirectory(uploadDir)) {
|
||||
return Map.of("uploadId", safeUploadId, "uploadedChunks", List.of());
|
||||
}
|
||||
return Map.of(
|
||||
"uploadId", safeUploadId,
|
||||
"uploadedChunks", listUploadedChunks(uploadDir)
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{roomCode}/file/upload/complete", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Map<String, Object> completeChunkUpload(
|
||||
@PathVariable String roomCode,
|
||||
@RequestBody CompleteUploadRequest request) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||
throw new IllegalArgumentException("房间号无效");
|
||||
}
|
||||
if (request == null || request.totalChunks() == null || request.totalChunks() <= 0) {
|
||||
throw new IllegalArgumentException("分片参数无效");
|
||||
}
|
||||
String safeUploadId = sanitizeUploadId(request.uploadId());
|
||||
if (safeUploadId.isBlank()) {
|
||||
throw new IllegalArgumentException("uploadId 无效");
|
||||
}
|
||||
|
||||
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||
if (!Files.isDirectory(uploadDir)) {
|
||||
throw new IllegalArgumentException("上传会话不存在");
|
||||
}
|
||||
|
||||
for (int i = 0; i < request.totalChunks(); i++) {
|
||||
Path chunkPath = uploadDir.resolve(i + ".part");
|
||||
if (!Files.isRegularFile(chunkPath)) {
|
||||
throw new IllegalArgumentException("缺少分片: " + i);
|
||||
}
|
||||
}
|
||||
|
||||
String fileId = "f_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
Path baseDir = resolveUploadRoot().resolve(roomCode);
|
||||
Files.createDirectories(baseDir);
|
||||
Path filePath = baseDir.resolve(fileId);
|
||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||
|
||||
String sha256 = mergeChunksAndComputeSha256(uploadDir, request.totalChunks(), filePath);
|
||||
String fileName = sanitizeFileName(request.fileName() != null ? request.fileName() : "download");
|
||||
String mimeType = request.mimeType() != null && !request.mimeType().isBlank()
|
||||
? request.mimeType()
|
||||
: "application/octet-stream";
|
||||
|
||||
Map<String, String> meta = Map.of(
|
||||
"fileName", sanitizeFileName(file.getOriginalFilename() != null ? file.getOriginalFilename() : "download"),
|
||||
"mimeType", file.getContentType() != null ? file.getContentType() : "application/octet-stream"
|
||||
"fileName", fileName,
|
||||
"mimeType", mimeType,
|
||||
"sha256", sha256
|
||||
);
|
||||
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
||||
|
||||
deleteDirectoryQuietly(uploadDir);
|
||||
|
||||
long fileSize = Files.size(filePath);
|
||||
return Map.<String, Object>of(
|
||||
"fileId", fileId,
|
||||
"fileName", meta.get("fileName"),
|
||||
"fileSize", file.getSize(),
|
||||
"mimeType", meta.get("mimeType")
|
||||
"fileName", fileName,
|
||||
"fileSize", fileSize,
|
||||
"mimeType", mimeType,
|
||||
"sha256", sha256
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/{roomCode}/file/{fileId}")
|
||||
public ResponseEntity<Resource> download(
|
||||
@PathVariable String roomCode,
|
||||
@PathVariable String fileId) throws IOException {
|
||||
@PathVariable String fileId,
|
||||
@RequestParam(value = "offset", required = false) Long offset) throws IOException {
|
||||
if (!isSafeRoomCode(roomCode) || !isSafeFileId(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
@@ -104,24 +272,54 @@ public class RoomFileController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
long fileSize = Files.size(filePath);
|
||||
long startOffset = (offset == null) ? 0L : Math.max(0L, offset);
|
||||
if (startOffset >= fileSize) {
|
||||
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
|
||||
}
|
||||
|
||||
String fileName = fileId;
|
||||
String mimeType = "application/octet-stream";
|
||||
String sha256 = null;
|
||||
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);
|
||||
sha256 = meta.get("sha256");
|
||||
}
|
||||
|
||||
String contentDisposition = "attachment; filename=\"" + escapeForQuotedString(fileName) + "\"";
|
||||
|
||||
Resource resource = new FileSystemResource(filePath);
|
||||
Resource resource;
|
||||
long contentLength;
|
||||
HttpStatus status;
|
||||
if (startOffset > 0) {
|
||||
InputStream in = Files.newInputStream(filePath);
|
||||
in.skipNBytes(startOffset);
|
||||
resource = new InputStreamResource(in);
|
||||
contentLength = fileSize - startOffset;
|
||||
status = HttpStatus.PARTIAL_CONTENT;
|
||||
} else {
|
||||
resource = new FileSystemResource(filePath);
|
||||
contentLength = fileSize;
|
||||
status = HttpStatus.OK;
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.status(status)
|
||||
.contentType(MediaType.parseMediaType(mimeType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||
.body(resource);
|
||||
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||
.contentLength(contentLength);
|
||||
if (startOffset > 0) {
|
||||
long end = fileSize - 1;
|
||||
responseBuilder.header(HttpHeaders.CONTENT_RANGE, "bytes " + startOffset + "-" + end + "/" + fileSize);
|
||||
}
|
||||
if (sha256 != null && !sha256.isBlank()) {
|
||||
responseBuilder.header("X-DataTool-SHA256", sha256);
|
||||
}
|
||||
return responseBuilder.body(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,4 +374,132 @@ public class RoomFileController {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private Path resolveTmpUploadDir(String roomCode, String uploadId) {
|
||||
return resolveUploadRoot()
|
||||
.resolve(UPLOAD_TMP_FOLDER)
|
||||
.resolve(roomCode)
|
||||
.resolve(uploadId);
|
||||
}
|
||||
|
||||
private static String sanitizeUploadId(String uploadId) {
|
||||
if (uploadId == null) return "";
|
||||
return uploadId.replaceAll("[^a-zA-Z0-9_-]", "");
|
||||
}
|
||||
|
||||
private static List<Integer> listUploadedChunks(Path uploadDir) throws IOException {
|
||||
if (!Files.isDirectory(uploadDir)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Integer> list = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.list(uploadDir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".part"))
|
||||
.map(name -> name.substring(0, name.length() - 5))
|
||||
.forEach(name -> {
|
||||
try {
|
||||
list.add(Integer.parseInt(name));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// ignore invalid part name
|
||||
}
|
||||
});
|
||||
}
|
||||
list.sort(Comparator.naturalOrder());
|
||||
return list;
|
||||
}
|
||||
|
||||
private static String writeFileAndComputeSha256(InputStream inputStream, Path filePath) throws IOException {
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
try (OutputStream out = Files.newOutputStream(filePath)) {
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
out.flush();
|
||||
} catch (IOException e) {
|
||||
Files.deleteIfExists(filePath);
|
||||
throw e;
|
||||
}
|
||||
return bytesToHex(digest.digest());
|
||||
}
|
||||
|
||||
private static String mergeChunksAndComputeSha256(Path uploadDir, int totalChunks, Path targetFilePath) throws IOException {
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
try (OutputStream out = Files.newOutputStream(targetFilePath)) {
|
||||
for (int i = 0; i < totalChunks; i++) {
|
||||
Path chunkPath = uploadDir.resolve(i + ".part");
|
||||
try (InputStream in = Files.newInputStream(chunkPath)) {
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, read);
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flush();
|
||||
} catch (IOException e) {
|
||||
Files.deleteIfExists(targetFilePath);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return bytesToHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void deleteDirectoryQuietly(Path dir) {
|
||||
if (dir == null || !Files.exists(dir)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
} catch (IOException ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte value : bytes) {
|
||||
sb.append(String.format("%02x", value));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public record InitUploadRequest(
|
||||
@NotNull String uploadId,
|
||||
@NotNull String fileName,
|
||||
@NotNull Long fileSize,
|
||||
String mimeType,
|
||||
@NotNull Integer totalChunks
|
||||
) {
|
||||
}
|
||||
|
||||
public record CompleteUploadRequest(
|
||||
@NotNull String uploadId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
@NotNull Integer totalChunks
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public class MessagePayload {
|
||||
// 发送方昵称(可选,用于前端展示)
|
||||
private String senderName;
|
||||
|
||||
// 房间加入令牌(JOIN 时使用)
|
||||
private String joinToken;
|
||||
|
||||
// 文本内容或图片 Base64 / URL(视前端实现而定)
|
||||
private String content;
|
||||
|
||||
@@ -38,6 +41,7 @@ public class MessagePayload {
|
||||
private String fileName;
|
||||
private Long fileSize;
|
||||
private String mimeType;
|
||||
private String sha256;
|
||||
/** IMAGE 类型小图直发:base64 图片数据(doc07) */
|
||||
private String imageData;
|
||||
private Integer chunkIndex;
|
||||
@@ -83,6 +87,14 @@ public class MessagePayload {
|
||||
this.senderName = senderName;
|
||||
}
|
||||
|
||||
public String getJoinToken() {
|
||||
return joinToken;
|
||||
}
|
||||
|
||||
public void setJoinToken(String joinToken) {
|
||||
this.joinToken = joinToken;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
@@ -139,6 +151,14 @@ public class MessagePayload {
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
public String getSha256() {
|
||||
return sha256;
|
||||
}
|
||||
|
||||
public void setSha256(String sha256) {
|
||||
this.sha256 = sha256;
|
||||
}
|
||||
|
||||
public String getImageData() {
|
||||
return imageData;
|
||||
}
|
||||
@@ -195,4 +215,3 @@ public class MessagePayload {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,14 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class Room {
|
||||
|
||||
private String roomCode;
|
||||
private final String joinToken;
|
||||
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
|
||||
private final Instant createdAt;
|
||||
private Instant expiresAt;
|
||||
|
||||
public Room(String roomCode) {
|
||||
public Room(String roomCode, String joinToken) {
|
||||
this.roomCode = roomCode;
|
||||
this.joinToken = joinToken;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
@@ -33,6 +35,10 @@ public class Room {
|
||||
this.roomCode = roomCode;
|
||||
}
|
||||
|
||||
public String getJoinToken() {
|
||||
return joinToken;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -65,4 +71,3 @@ public class Room {
|
||||
return Collections.unmodifiableCollection(sessions.values());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import java.util.stream.Collectors;
|
||||
public class RoomService {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String TOKEN_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private static final int TOKEN_LENGTH = 8;
|
||||
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final UploadCleanupService uploadCleanupService;
|
||||
@@ -36,13 +38,14 @@ public class RoomService {
|
||||
/**
|
||||
* 创建房间:生成唯一 6 位数字房间号并创建房间。
|
||||
*/
|
||||
public String createRoom() {
|
||||
public CreateRoomResult createRoom() {
|
||||
String roomCode;
|
||||
do {
|
||||
roomCode = String.valueOf(100000 + RANDOM.nextInt(900000));
|
||||
} while (rooms.containsKey(roomCode));
|
||||
rooms.put(roomCode, new Room(roomCode));
|
||||
return roomCode;
|
||||
String joinToken = generateJoinToken();
|
||||
rooms.put(roomCode, new Room(roomCode, joinToken));
|
||||
return new CreateRoomResult(roomCode, joinToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,8 +69,11 @@ public class RoomService {
|
||||
/**
|
||||
* 用户加入房间。
|
||||
*/
|
||||
public Room joinRoom(String roomCode, String sessionId, String userId, String nickname) {
|
||||
Room room = rooms.computeIfAbsent(roomCode, Room::new);
|
||||
public Room joinRoom(String roomCode, String joinToken, String sessionId, String userId, String nickname) {
|
||||
Room room = rooms.get(roomCode);
|
||||
if (room == null || !verifyJoinToken(roomCode, joinToken)) {
|
||||
throw new IllegalArgumentException("房间不存在或加入令牌错误");
|
||||
}
|
||||
String uniqueNickname = ensureUniqueNickname(roomCode, nickname);
|
||||
SessionInfo sessionInfo = new SessionInfo(sessionId, userId, uniqueNickname, Instant.now());
|
||||
room.addSession(sessionInfo);
|
||||
@@ -102,6 +108,14 @@ public class RoomService {
|
||||
return roomCode != null && rooms.containsKey(roomCode);
|
||||
}
|
||||
|
||||
public boolean verifyJoinToken(String roomCode, String joinToken) {
|
||||
if (roomCode == null || roomCode.isBlank() || joinToken == null || joinToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
Room room = rooms.get(roomCode);
|
||||
return room != null && joinToken.equalsIgnoreCase(room.getJoinToken());
|
||||
}
|
||||
|
||||
public Collection<SessionInfo> getUsers(String roomCode) {
|
||||
Room room = rooms.get(roomCode);
|
||||
return room == null ? java.util.List.of() : room.getSessions();
|
||||
@@ -114,5 +128,16 @@ public class RoomService {
|
||||
Room room = rooms.get(roomCode);
|
||||
return room == null ? null : room.getSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private static String generateJoinToken() {
|
||||
StringBuilder sb = new StringBuilder(TOKEN_LENGTH);
|
||||
for (int i = 0; i < TOKEN_LENGTH; i++) {
|
||||
int idx = RANDOM.nextInt(TOKEN_CHARS.length());
|
||||
sb.append(TOKEN_CHARS.charAt(idx));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public record CreateRoomResult(String roomCode, String joinToken) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileStore;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
@@ -67,5 +70,90 @@ public class ScheduledUploadCleanup {
|
||||
} catch (IOException e) {
|
||||
log.warn("[ScheduledUploadCleanup] 列出上传目录失败 error={}", e.getMessage());
|
||||
}
|
||||
|
||||
cleanupByDiskWatermark(root);
|
||||
}
|
||||
|
||||
private void cleanupByDiskWatermark(Path root) {
|
||||
int high = transferProperties.getDiskHighWatermarkPercent();
|
||||
int target = transferProperties.getDiskTargetWatermarkPercent();
|
||||
if (high <= 0 || target <= 0 || target >= high || high >= 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
double used = getDiskUsedPercent(root);
|
||||
if (used < 0 || used < high) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> inactiveRooms = listInactiveRoomsByOldest(root);
|
||||
if (inactiveRooms.isEmpty()) {
|
||||
log.warn("[ScheduledUploadCleanup] 磁盘使用率偏高({}%),但无可清理的离线房间目录", Math.round(used));
|
||||
return;
|
||||
}
|
||||
|
||||
int deletedCount = 0;
|
||||
for (String roomCode : inactiveRooms) {
|
||||
if (used <= target) {
|
||||
break;
|
||||
}
|
||||
if (uploadCleanupService.deleteRoomFolder(roomCode)) {
|
||||
deletedCount++;
|
||||
used = getDiskUsedPercent(root);
|
||||
if (used < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
if (used >= 0) {
|
||||
log.info("[ScheduledUploadCleanup] 磁盘水位回收完成 deletedRooms={} usedPercent={} targetPercent={}",
|
||||
deletedCount,
|
||||
Math.round(used),
|
||||
target);
|
||||
} else {
|
||||
log.info("[ScheduledUploadCleanup] 磁盘水位回收完成 deletedRooms={}", deletedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> listInactiveRoomsByOldest(Path root) {
|
||||
try (Stream<Path> list = Files.list(root)) {
|
||||
return list
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getFileName().toString().matches(ROOM_CODE_PATTERN))
|
||||
.filter(path -> !roomService.hasRoom(path.getFileName().toString()))
|
||||
.sorted(Comparator.comparingLong(this::safeLastModifiedMillis))
|
||||
.map(path -> path.getFileName().toString())
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
log.warn("[ScheduledUploadCleanup] 获取离线房间目录失败 error={}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private long safeLastModifiedMillis(Path path) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toMillis();
|
||||
} catch (IOException e) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private double getDiskUsedPercent(Path root) {
|
||||
try {
|
||||
FileStore store = Files.getFileStore(root);
|
||||
long total = store.getTotalSpace();
|
||||
long usable = store.getUsableSpace();
|
||||
if (total <= 0) {
|
||||
return -1;
|
||||
}
|
||||
long used = total - usable;
|
||||
return (used * 100.0) / total;
|
||||
} catch (IOException e) {
|
||||
log.warn("[ScheduledUploadCleanup] 读取磁盘使用率失败 error={}", e.getMessage());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.datatool.message.MessageType;
|
||||
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.messaging.handler.annotation.DestinationVariable;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
||||
@@ -30,6 +32,8 @@ import java.util.Map;
|
||||
@Controller
|
||||
public class RoomWsController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RoomWsController.class);
|
||||
|
||||
private final RoomService roomService;
|
||||
private final SessionRegistry sessionRegistry;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
@@ -52,8 +56,14 @@ public class RoomWsController {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
String userId = payload.getSenderId();
|
||||
String nickname = payload.getContent(); // 暂将 content 视为昵称占位,后续可单独扩展字段
|
||||
String joinToken = payload.getJoinToken();
|
||||
|
||||
roomService.joinRoom(roomCode, sessionId, userId, nickname);
|
||||
try {
|
||||
roomService.joinRoom(roomCode, joinToken, sessionId, userId, nickname);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.warn("[WS] 加入房间失败: roomCode={}, sessionId={}, reason={}", roomCode, sessionId, ex.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
String joinMessage = (nickname != null && !nickname.isEmpty()) ? nickname + " 加入房间" : "有用户加入房间";
|
||||
Map<String, Object> joinData = new HashMap<>();
|
||||
@@ -123,4 +133,3 @@ public class RoomWsController {
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,4 +23,5 @@ datatool:
|
||||
max-file-size: 2147483648 # 2GB
|
||||
room-expire-hours: 24
|
||||
cleanup-interval-ms: 3600000 # 定时过期清理间隔(毫秒),默认 1 小时
|
||||
|
||||
disk-high-watermark-percent: 80 # 磁盘使用率高于该值时,回收离线房间目录
|
||||
disk-target-watermark-percent: 70 # 回收到低于该值即停止
|
||||
|
||||
Reference in New Issue
Block a user