diff --git a/README.md b/README.md index 5851250..052f843 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 基于 WebSocket 的轻量级数据传输工具,适用于 VNC/远程桌面、内网隔离等场景。 +> 安全更新:创建房间后会生成 8 位加入令牌,加入方需同时提供「房间号 + 令牌」。 + ## 技术栈 - **后端**: Spring Boot 3.2.5 + Java 17 + WebSocket (STOMP/SockJS) @@ -112,6 +114,19 @@ DataTool/ 后续功能开发请参考 `docs/` 目录下的详细文档。 +## 压力测试 + +仓库提供压测脚本:`scripts/load-test.mjs`,支持直传与分块模式。 + +示例: + +```bash +LOAD_BASE_URL="http://127.0.0.1:18080" LOAD_MODE=direct LOAD_CONCURRENCY=8 LOAD_REQUESTS=64 LOAD_FILE_KB=512 node scripts/load-test.mjs +LOAD_BASE_URL="http://127.0.0.1:18080" LOAD_MODE=chunked LOAD_CONCURRENCY=4 LOAD_REQUESTS=24 LOAD_FILE_KB=8192 node scripts/load-test.mjs +``` + +本次基线结果见:`docs/12-压力测试报告.md`。 + ## 许可证 [待定] diff --git a/backend/src/main/java/com/datatool/config/TransferProperties.java b/backend/src/main/java/com/datatool/config/TransferProperties.java index d07294a..8f6af29 100644 --- a/backend/src/main/java/com/datatool/config/TransferProperties.java +++ b/backend/src/main/java/com/datatool/config/TransferProperties.java @@ -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; + } } diff --git a/backend/src/main/java/com/datatool/controller/RoomController.java b/backend/src/main/java/com/datatool/controller/RoomController.java index af67f4b..969954e 100644 --- a/backend/src/main/java/com/datatool/controller/RoomController.java +++ b/backend/src/main/java/com/datatool/controller/RoomController.java @@ -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 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> 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)); } /** diff --git a/backend/src/main/java/com/datatool/controller/RoomFileController.java b/backend/src/main/java/com/datatool/controller/RoomFileController.java index 8bf4415..7444be0 100644 --- a/backend/src/main/java/com/datatool/controller/RoomFileController.java +++ b/backend/src/main/java/com/datatool/controller/RoomFileController.java @@ -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 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.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 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 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 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 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 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 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 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.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 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 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 listUploadedChunks(Path uploadDir) throws IOException { + if (!Files.isDirectory(uploadDir)) { + return List.of(); + } + List list = new ArrayList<>(); + try (Stream 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 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 + ) { + } } diff --git a/backend/src/main/java/com/datatool/message/MessagePayload.java b/backend/src/main/java/com/datatool/message/MessagePayload.java index db43aae..77d5278 100644 --- a/backend/src/main/java/com/datatool/message/MessagePayload.java +++ b/backend/src/main/java/com/datatool/message/MessagePayload.java @@ -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; } } - diff --git a/backend/src/main/java/com/datatool/room/Room.java b/backend/src/main/java/com/datatool/room/Room.java index 8b71ec0..46d3f84 100644 --- a/backend/src/main/java/com/datatool/room/Room.java +++ b/backend/src/main/java/com/datatool/room/Room.java @@ -16,12 +16,14 @@ import java.util.concurrent.ConcurrentHashMap; public class Room { private String roomCode; + private final String joinToken; private final Map 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()); } } - diff --git a/backend/src/main/java/com/datatool/room/RoomService.java b/backend/src/main/java/com/datatool/room/RoomService.java index ccffcc4..3587441 100644 --- a/backend/src/main/java/com/datatool/room/RoomService.java +++ b/backend/src/main/java/com/datatool/room/RoomService.java @@ -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 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 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) { + } +} diff --git a/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java b/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java index 8da1b26..59733ac 100644 --- a/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java +++ b/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java @@ -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 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 listInactiveRoomsByOldest(Path root) { + try (Stream 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; + } } } diff --git a/backend/src/main/java/com/datatool/ws/RoomWsController.java b/backend/src/main/java/com/datatool/ws/RoomWsController.java index 1b5fd52..ce7591f 100644 --- a/backend/src/main/java/com/datatool/ws/RoomWsController.java +++ b/backend/src/main/java/com/datatool/ws/RoomWsController.java @@ -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 joinData = new HashMap<>(); @@ -123,4 +133,3 @@ public class RoomWsController { messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload); } } - diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 844564d..9d3ab9e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 # 回收到低于该值即停止 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1362f81..52b020a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -920,7 +920,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1183,7 +1182,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1815,7 +1813,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2063,7 +2060,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2556,7 +2552,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2590,7 +2585,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2660,7 +2654,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2720,7 +2713,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", diff --git a/frontend/src/api/room.ts b/frontend/src/api/room.ts index 747e2d0..94322a2 100644 --- a/frontend/src/api/room.ts +++ b/frontend/src/api/room.ts @@ -8,6 +8,27 @@ export interface UploadFileResponse { fileName: string; fileSize: number; mimeType: string; + sha256?: string; +} + +export interface UploadProgressDetail { + mode: 'direct' | 'chunked'; + chunkSizeBytes?: number; + totalChunks?: number; + uploadedChunks?: number; + resumedChunks?: number; + retriesUsed?: number; +} + +interface InitChunkUploadResponse { + uploadId: string; + uploadedChunks: number[]; + totalChunks: number; +} + +export interface CreateRoomResponse { + roomCode: string; + joinToken: string; } /** @@ -18,6 +39,21 @@ export async function uploadRoomFile( roomCode: string, file: File, onProgress?: (percent: number) => void, + onDetail?: (detail: UploadProgressDetail) => void, +): Promise { + const LARGE_FILE_THRESHOLD = 8 * 1024 * 1024; + if (file.size <= LARGE_FILE_THRESHOLD) { + return uploadRoomFileDirect(roomCode, file, onProgress, onDetail); + } + + return uploadRoomFileByChunks(roomCode, file, onProgress, onDetail); +} + +function uploadRoomFileDirect( + roomCode: string, + file: File, + onProgress?: (percent: number) => void, + onDetail?: (detail: UploadProgressDetail) => void, ): Promise { const formData = new FormData(); formData.append('file', file); @@ -38,6 +74,7 @@ export async function uploadRoomFile( if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText) as UploadFileResponse; + onDetail?.({ mode: 'direct' }); resolve(data); } catch { reject(new Error('解析响应失败')); @@ -61,6 +98,169 @@ export async function uploadRoomFile( }); } +async function uploadRoomFileByChunks( + roomCode: string, + file: File, + onProgress?: (percent: number) => void, + onDetail?: (detail: UploadProgressDetail) => void, +): Promise { + const chunkSize = getAdaptiveUploadChunkSize(file.size); + const totalChunks = Math.ceil(file.size / chunkSize); + const uploadId = buildUploadId(file, chunkSize); + + const initRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploadId, + fileName: file.name, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream', + totalChunks, + }), + }); + if (!initRes.ok) { + throw new Error(`初始化分块上传失败 ${initRes.status}`); + } + + const initData = (await initRes.json()) as InitChunkUploadResponse; + const uploaded = new Set(initData.uploadedChunks ?? []); + onDetail?.({ + mode: 'chunked', + chunkSizeBytes: chunkSize, + totalChunks, + uploadedChunks: uploaded.size, + resumedChunks: uploaded.size, + }); + if (onProgress && totalChunks > 0) { + onProgress(Math.round((uploaded.size / totalChunks) * 100)); + } + + for (let i = 0; i < totalChunks; i++) { + if (uploaded.has(i)) continue; + const start = i * chunkSize; + const end = Math.min(file.size, start + chunkSize); + const chunkBlob = file.slice(start, end); + + const retriesUsed = await uploadChunkWithRetry(roomCode, initData.uploadId, i, chunkBlob, 3); + uploaded.add(i); + onDetail?.({ + mode: 'chunked', + chunkSizeBytes: chunkSize, + totalChunks, + uploadedChunks: uploaded.size, + retriesUsed, + }); + if (onProgress && totalChunks > 0) { + onProgress(Math.round((uploaded.size / totalChunks) * 100)); + } + } + + const completeRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploadId: initData.uploadId, + fileName: file.name, + mimeType: file.type || 'application/octet-stream', + totalChunks, + }), + }); + if (!completeRes.ok) { + throw new Error(`分块合并失败 ${completeRes.status}`); + } + + return (await completeRes.json()) as UploadFileResponse; +} + +async function uploadChunkWithRetry( + roomCode: string, + uploadId: string, + chunkIndex: number, + chunkBlob: Blob, + maxRetries: number, +): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const formData = new FormData(); + formData.append('chunk', chunkBlob, `${chunkIndex}.part`); + const res = await fetch( + `/api/room/${encodeURIComponent(roomCode)}/file/upload/chunk?uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${chunkIndex}`, + { + method: 'POST', + body: formData, + }, + ); + if (!res.ok) { + throw new Error(`分片 ${chunkIndex} 上传失败 ${res.status}`); + } + return attempt; + } catch (err) { + if (err instanceof Error) { + lastError = err; + } else { + lastError = new Error(`分片 ${chunkIndex} 上传失败(网络错误)`); + } + if (attempt < maxRetries) { + await sleep(300 * (attempt + 1)); + } + } + } + throw lastError ?? new Error('分片上传失败'); +} + +function buildUploadId(file: File, chunkSize: number): string { + const source = `${file.name}_${file.size}_${file.lastModified}_${chunkSize}`; + let hash = 2166136261; + for (let index = 0; index < source.length; index++) { + hash ^= source.charCodeAt(index); + hash += + (hash << 1) + + (hash << 4) + + (hash << 7) + + (hash << 8) + + (hash << 24); + } + return `up_${Math.abs(hash >>> 0).toString(36)}`; +} + +function getAdaptiveUploadChunkSize(fileSize: number): number { + const MB = 1024 * 1024; + const connection = getNetworkConnection(); + const effectiveType = connection?.effectiveType ?? ''; + const saveData = connection?.saveData ?? false; + + if (saveData || effectiveType === 'slow-2g' || effectiveType === '2g') { + return 512 * 1024; + } + if (effectiveType === '3g') { + return MB; + } + if (fileSize >= 1024 * MB) { + return 4 * MB; + } + if (fileSize >= 256 * MB) { + return 2 * MB; + } + return 1536 * 1024; +} + +function getNetworkConnection(): + | { effectiveType?: string; saveData?: boolean } + | undefined { + const nav = navigator as Navigator & { + connection?: { effectiveType?: string; saveData?: boolean }; + mozConnection?: { effectiveType?: string; saveData?: boolean }; + webkitConnection?: { effectiveType?: string; saveData?: boolean }; + }; + return nav.connection ?? nav.mozConnection ?? nav.webkitConnection; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** * 获取当前客户端的 IP(由服务端从请求中解析),用于作为默认昵称。 */ @@ -72,6 +272,24 @@ export async function getMyIp(apiBase = ''): Promise { return data.ip ?? ''; } +export async function createRoom(apiBase = ''): Promise { + const url = `${apiBase}/api/room/create`.replace(/\/+/g, '/'); + const res = await fetch(url, { method: 'POST' }); + if (!res.ok) throw new Error(`创建房间失败: ${res.status}`); + return (await res.json()) as CreateRoomResponse; +} + +export async function verifyRoomAccess( + roomCode: string, + joinToken: string, + apiBase = '', +): Promise { + const params = new URLSearchParams({ roomCode, joinToken }); + const url = `${apiBase}/api/room/verify?${params.toString()}`.replace(/\/+/g, '/'); + const res = await fetch(url); + return res.ok; +} + /** * 返回房间内文件的下载 URL(相对路径,走当前 origin 的 /api 代理)。 */ @@ -86,26 +304,83 @@ export function downloadWithProgress( url: string, onProgress?: (percent: number) => void, ): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.responseType = 'blob'; - - xhr.addEventListener('progress', (e) => { - if (onProgress && e.lengthComputable && e.total > 0) { - onProgress(Math.round((e.loaded / e.total) * 100)); - } - }); - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr.response as Blob); - } else { - reject(new Error(xhr.status === 404 ? '文件不存在' : `下载失败 ${xhr.status}`)); - } - }); - xhr.addEventListener('error', () => reject(new Error('网络错误'))); - xhr.addEventListener('abort', () => reject(new Error('已取消'))); - - xhr.open('GET', url); - xhr.send(); - }); + return downloadWithResume(url, onProgress, 2); +} + +async function downloadWithResume( + url: string, + onProgress?: (percent: number) => void, + maxRetries = 2, +): Promise { + let downloaded = 0; + let totalSize: number | null = null; + const chunks: Uint8Array[] = []; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const requestUrl = appendOffset(url, downloaded > 0 ? downloaded : null); + const response = await fetch(requestUrl); + + if (!response.ok) { + throw new Error(response.status === 404 ? '文件不存在' : `下载失败 ${response.status}`); + } + + const contentRange = response.headers.get('Content-Range'); + const contentLength = Number(response.headers.get('Content-Length') ?? '0'); + + if (downloaded > 0 && response.status === 200) { + downloaded = 0; + totalSize = null; + chunks.length = 0; + } + + if (totalSize == null) { + if (contentRange) { + const match = contentRange.match(/\/([0-9]+)$/); + if (match) { + totalSize = Number(match[1]); + } + } + if (totalSize == null && contentLength > 0) { + totalSize = downloaded + contentLength; + } + } + + if (!response.body) { + throw new Error('下载流不可用'); + } + + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + chunks.push(value); + downloaded += value.length; + if (onProgress && totalSize && totalSize > 0) { + onProgress(Math.min(100, Math.round((downloaded / totalSize) * 100))); + } + } + + const blob = new Blob(chunks, { + type: response.headers.get('Content-Type') ?? 'application/octet-stream', + }); + if (onProgress) onProgress(100); + return blob; + } catch (err) { + if (attempt >= maxRetries) { + if (err instanceof Error) throw err; + throw new Error('网络错误'); + } + } + } + + throw new Error('下载失败'); +} + +function appendOffset(url: string, offset: number | null): string { + if (offset == null || offset <= 0) return url; + const hasQuery = url.includes('?'); + const sep = hasQuery ? '&' : '?'; + return `${url}${sep}offset=${offset}`; } diff --git a/frontend/src/components/FileDropZone.vue b/frontend/src/components/FileDropZone.vue index 693349e..3db0dab 100644 --- a/frontend/src/components/FileDropZone.vue +++ b/frontend/src/components/FileDropZone.vue @@ -22,6 +22,15 @@ multiple @change="handleInputChange" /> +
+
-

传输失败

+

+ 传输失败 · {{ chunkHint }} +

- 重试 + 重试失败分片
@@ -96,6 +98,7 @@ const props = withDefaults( isMe?: boolean; status?: 'sending' | 'receiving' | 'done' | 'error'; progress?: number; // 0–100 + chunkHint?: string; }>(), { fileSize: 0, @@ -130,8 +133,16 @@ const showProgress = computed( ); const statusLabel = computed(() => { - if (props.status === 'sending') return `发送中 ${props.progress}%`; - if (props.status === 'receiving') return `接收中 ${props.progress}%`; + if (props.status === 'sending') { + return props.chunkHint + ? `发送中 ${props.progress}% · ${props.chunkHint}` + : `发送中 ${props.progress}%`; + } + if (props.status === 'receiving') { + return props.chunkHint + ? `接收中 ${props.progress}% · ${props.chunkHint}` + : `接收中 ${props.progress}%`; + } return ''; }); diff --git a/frontend/src/components/ImageMessage.vue b/frontend/src/components/ImageMessage.vue index f19784c..542f54c 100644 --- a/frontend/src/components/ImageMessage.vue +++ b/frontend/src/components/ImageMessage.vue @@ -46,6 +46,7 @@

{{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}% + · {{ chunkHint }}

+
+

+ 传输失败 · {{ chunkHint }} +

+ +
+
(), { isMe: false, @@ -212,6 +231,7 @@ const props = withDefaults( const emit = defineEmits<{ download: []; + retry: []; }>(); const showPreview = ref(false); @@ -255,6 +275,10 @@ function handleDownload() { emit('download'); } +function handleRetry() { + emit('retry'); +} + // ESC 键关闭预览 if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { diff --git a/frontend/src/components/MessageItem.vue b/frontend/src/components/MessageItem.vue index 892ba6f..16f86cb 100644 --- a/frontend/src/components/MessageItem.vue +++ b/frontend/src/components/MessageItem.vue @@ -28,6 +28,19 @@ > {{ shortDisplayName }} + diff --git a/frontend/src/stores/wsStore.ts b/frontend/src/stores/wsStore.ts index 114f205..745a1ab 100644 --- a/frontend/src/stores/wsStore.ts +++ b/frontend/src/stores/wsStore.ts @@ -15,14 +15,23 @@ const MAX_HISTORY_ITEMS = 500; const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000; /** 超过此长度则分片发送(与 doc05 一致,如 32KB) */ const CHUNK_THRESHOLD = 32 * 1024; -const CHUNK_SIZE = 32 * 1024; +const CHUNK_SIZE_SMALL = 32 * 1024; +const CHUNK_SIZE_MEDIUM = 64 * 1024; +const CHUNK_SIZE_LARGE = 96 * 1024; /** 分片缓存超时(ms),超时未收齐则清理并可选提示 */ const CHUNK_CACHE_TTL = 60 * 1000; /** 单文件大小上限(2GB),与后端 transfer.max-file-size 一致 */ const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; +/** 接收端背压:文件分片缓存的最大并发文件数 */ +const MAX_ACTIVE_FILE_CHUNK_ENTRIES = 12; +/** 接收端背压:文本分片缓存的最大并发消息数 */ +const MAX_ACTIVE_TEXT_CHUNK_ENTRIES = 200; +/** 发送端队列上限,避免一次性堆积过多任务 */ +const MAX_SEND_QUEUE_ITEMS = 50; interface JoinRoomPayload { roomCode: string; + joinToken?: string; senderId?: string; nickname?: string; } @@ -62,6 +71,19 @@ function saveHistory(roomCode: string, messages: RoomMessagePayload[]): void { } } +function getAdaptiveChunkSize(contentLength: number, status: ConnectionStatus): number { + if (status === 'reconnecting') { + return CHUNK_SIZE_SMALL; + } + if (contentLength >= 1024 * 1024) { + return CHUNK_SIZE_LARGE; + } + if (contentLength >= 256 * 1024) { + return CHUNK_SIZE_MEDIUM; + } + return CHUNK_SIZE_SMALL; +} + export const useWsStore = defineStore('ws', () => { const status = ref('disconnected'); const client = ref(null); @@ -76,6 +98,11 @@ export const useWsStore = defineStore('ws', () => { const downloadingFileIds = ref>({}); /** 文件发送进度 fileId -> 0-100 */ const sendingProgress = ref>({}); + const uploadChunkStats = ref>({}); + const uploadRetryCount = ref(0); + const uploadResumeCount = ref(0); + const uploadQueueSize = ref(0); + const failedUploads = ref>({}); /** 当前正在发送的 fileId,用于收到自己发的 FILE 时不重复追加 */ const sendingFileIds = new Set(); /** 接收完成后 fileId -> object URL,用于下载 */ @@ -110,6 +137,23 @@ export const useWsStore = defineStore('ws', () => { receivedAt: number; } >(); + const fileSendQueue: Array<{ + roomCode: string; + file: File; + resolve: (result: { ok: boolean; error?: string }) => void; + }> = []; + const sendingQueueBusy = ref(false); + + function pushSystemTip(message: string): void { + if (!currentRoomCode.value) return; + roomMessages.value.push({ + roomCode: currentRoomCode.value, + type: 'SYSTEM', + senderId: 'system', + data: { message }, + }); + saveHistory(currentRoomCode.value, roomMessages.value); + } function init(baseUrl: string, endpoint = '/ws') { if (!client.value) { @@ -135,6 +179,11 @@ export const useWsStore = defineStore('ws', () => { fileChunkCache.clear(); sendingFileIds.clear(); sendingProgress.value = {}; + uploadChunkStats.value = {}; + fileSendQueue.length = 0; + uploadQueueSize.value = 0; + failedUploads.value = {}; + sendingQueueBusy.value = false; client.value?.disconnect(); status.value = 'disconnected'; currentRoomCode.value = null; @@ -176,6 +225,10 @@ export const useWsStore = defineStore('ws', () => { const messageId = payload.messageId; let entry = textChunkCache.get(messageId); if (!entry) { + if (textChunkCache.size >= MAX_ACTIVE_TEXT_CHUNK_ENTRIES) { + pushSystemTip('接收文本分片过多,已启用限流,请稍后重试'); + return; + } entry = { chunks: new Map(), totalChunks: payload.totalChunks, @@ -214,6 +267,10 @@ export const useWsStore = defineStore('ws', () => { payload.totalChunks != null && payload.storage !== 'server' ) { + if (!fileChunkCache.has(fileId) && fileChunkCache.size >= MAX_ACTIVE_FILE_CHUNK_ENTRIES) { + pushSystemTip('接收文件任务过多,已启用限流,请稍后重试'); + return; + } fileChunkCache.set(fileId, { meta: { fileName: payload.fileName ?? '未命名', @@ -353,6 +410,7 @@ export const useWsStore = defineStore('ws', () => { roomCode: payload.roomCode, type: 'SYSTEM', senderId, + joinToken: payload.joinToken, content: nickname, }, handleRoomMessage, @@ -376,6 +434,11 @@ export const useWsStore = defineStore('ws', () => { fileChunkCache.clear(); sendingFileIds.clear(); sendingProgress.value = {}; + uploadChunkStats.value = {}; + fileSendQueue.length = 0; + uploadQueueSize.value = 0; + failedUploads.value = {}; + sendingQueueBusy.value = false; currentRoomCode.value = null; userList.value = []; roomMessages.value = []; @@ -394,11 +457,13 @@ export const useWsStore = defineStore('ws', () => { }); return; } + + const chunkSize = getAdaptiveChunkSize(content.length, status.value); const messageId = `${myUserId.value}_${Date.now()}`; - const totalChunks = Math.ceil(content.length / CHUNK_SIZE); + const totalChunks = Math.ceil(content.length / chunkSize); for (let i = 0; i < totalChunks; i++) { - const start = i * CHUNK_SIZE; - const chunk = content.slice(start, start + CHUNK_SIZE); + const start = i * chunkSize; + const chunk = content.slice(start, start + chunkSize); client.value.sendMessage(roomCode, { roomCode, type: 'TEXT', @@ -453,7 +518,40 @@ export const useWsStore = defineStore('ws', () => { return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); } - async function sendFile( + async function processFileSendQueue(): Promise { + if (sendingQueueBusy.value) return; + if (fileSendQueue.length === 0) return; + const task = fileSendQueue.shift(); + uploadQueueSize.value = fileSendQueue.length; + if (!task) return; + sendingQueueBusy.value = true; + try { + const result = await sendFileInternal(task.roomCode, task.file); + task.resolve(result); + } finally { + sendingQueueBusy.value = false; + if (fileSendQueue.length > 0) { + void processFileSendQueue(); + } + } + } + + function sendFile( + roomCode: string, + file: File, + ): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + if (fileSendQueue.length >= MAX_SEND_QUEUE_ITEMS) { + resolve({ ok: false, error: '发送队列已满,请稍后再试' }); + return; + } + fileSendQueue.push({ roomCode, file, resolve }); + uploadQueueSize.value = fileSendQueue.length; + void processFileSendQueue(); + }); + } + + async function sendFileInternal( roomCode: string, file: File, ): Promise<{ ok: boolean; error?: string }> { @@ -493,15 +591,36 @@ export const useWsStore = defineStore('ws', () => { } try { + let resumeReported = false; const res = await uploadRoomFile(roomCode, file, (percent) => { sendingProgress.value[tempFileId] = percent; sendingProgress.value = { ...sendingProgress.value }; + }, (detail) => { + if (detail.mode === 'chunked' && detail.totalChunks && detail.uploadedChunks != null) { + uploadChunkStats.value[tempFileId] = { + uploaded: detail.uploadedChunks, + total: detail.totalChunks, + }; + uploadChunkStats.value = { ...uploadChunkStats.value }; + } + if (detail.retriesUsed && detail.retriesUsed > 0) { + uploadRetryCount.value += detail.retriesUsed; + } + if (!resumeReported && (detail.resumedChunks ?? 0) > 0) { + resumeReported = true; + uploadResumeCount.value += 1; + pushSystemTip(`文件「${file.name}」命中断点续传,已复用 ${detail.resumedChunks} 个分片`); + } }); const actualFileId = res.fileId; sendingFileIds.delete(tempFileId); sendingFileIds.add(actualFileId); sendingProgress.value[actualFileId] = 100; delete sendingProgress.value[tempFileId]; + delete uploadChunkStats.value[tempFileId]; + uploadChunkStats.value = { ...uploadChunkStats.value }; + delete failedUploads.value[tempFileId]; + failedUploads.value = { ...failedUploads.value }; const payload: RoomMessagePayload = { ...optimistic, @@ -509,6 +628,7 @@ export const useWsStore = defineStore('ws', () => { fileName: res.fileName, fileSize: res.fileSize, mimeType: res.mimeType, + sha256: res.sha256, storage: 'server', }; const idx = roomMessages.value.findIndex( @@ -532,17 +652,16 @@ export const useWsStore = defineStore('ws', () => { } catch (e) { console.error('[sendFile] 上传失败:', e); sendingFileIds.delete(tempFileId); - delete sendingProgress.value[tempFileId]; - const url = imageBlobUrls.value[tempFileId]; - if (url) { - URL.revokeObjectURL(url); - delete imageBlobUrls.value[tempFileId]; - } - const removeIdx = roomMessages.value.findIndex( - (m) => m.type === 'FILE' && m.fileId === tempFileId && m.senderId === myUserId.value, - ); - if (removeIdx !== -1) roomMessages.value.splice(removeIdx, 1); const errMsg = e instanceof Error ? e.message : '发送失败,请重试'; + const failedChunkIndex = extractFailedChunkIndex(errMsg); + failedUploads.value[tempFileId] = { + roomCode, + file, + reason: errMsg, + failedChunkIndex, + }; + failedUploads.value = { ...failedUploads.value }; + pushSystemTip(`文件「${file.name}」上传失败,可点击重试继续上传`); return { ok: false, error: errMsg }; } } @@ -553,6 +672,58 @@ export const useWsStore = defineStore('ws', () => { return fileProgress.value[fileId] ?? 0; } + function getUploadChunkHint(fileId: string): string | null { + const failed = failedUploads.value[fileId]; + if (failed) { + if (failed.failedChunkIndex != null) { + return `失败分片 #${failed.failedChunkIndex}`; + } + return '上传失败'; + } + const stat = uploadChunkStats.value[fileId]; + if (!stat || stat.total <= 0) return null; + return `${stat.uploaded}/${stat.total} 分片`; + } + + function isFailedUpload(fileId: string): boolean { + return !!failedUploads.value[fileId]; + } + + async function retryFailedUpload(fileId: string): Promise<{ ok: boolean; error?: string }> { + const failed = failedUploads.value[fileId]; + if (!failed) { + return { ok: false, error: '未找到失败上传任务' }; + } + const roomCode = failed.roomCode; + const file = failed.file; + const idx = roomMessages.value.findIndex( + (m) => m.type === 'FILE' && m.fileId === fileId && m.senderId === myUserId.value, + ); + if (idx !== -1) { + roomMessages.value.splice(idx, 1); + } + delete failedUploads.value[fileId]; + failedUploads.value = { ...failedUploads.value }; + delete sendingProgress.value[fileId]; + delete uploadChunkStats.value[fileId]; + uploadChunkStats.value = { ...uploadChunkStats.value }; + const url = imageBlobUrls.value[fileId]; + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + delete imageBlobUrls.value[fileId]; + + const result = await sendFile(roomCode, file); + return result; + } + + function extractFailedChunkIndex(errorMessage: string): number | undefined { + const match = errorMessage.match(/分片\s*(\d+)/); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isFinite(value) ? value : undefined; + } + /** 设置文件进度(用于下载时在 UI 显示 接收中 X%) */ function setFileProgress(fileId: string, percent: number): void { fileProgress.value[fileId] = percent; @@ -674,6 +845,9 @@ export const useWsStore = defineStore('ws', () => { userList, roomMessages, fileProgress, + uploadRetryCount, + uploadResumeCount, + uploadQueueSize, init, connect, disconnect, @@ -685,6 +859,9 @@ export const useWsStore = defineStore('ws', () => { exportRoomHistory, exportRoomHistoryAsText, getFileProgress, + getUploadChunkHint, + isFailedUpload, + retryFailedUpload, setFileProgress, setDownloading, isDownloading, diff --git a/frontend/src/types/room.ts b/frontend/src/types/room.ts index bf1b64e..06d8c75 100644 --- a/frontend/src/types/room.ts +++ b/frontend/src/types/room.ts @@ -21,6 +21,7 @@ export interface RoomMessagePayload { roomCode?: string; senderId?: string; senderName?: string; + joinToken?: string; timestamp?: number; /** 文本内容(TEXT) */ content?: string; @@ -35,6 +36,7 @@ export interface RoomMessagePayload { fileName?: string; fileSize?: number; mimeType?: string; + sha256?: string; chunkIndex?: number; /** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */ totalChunks?: number; diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts index 12b221d..57e3bed 100644 --- a/frontend/src/utils/clipboard.ts +++ b/frontend/src/utils/clipboard.ts @@ -9,6 +9,11 @@ export interface ClipboardReadResult { error?: string; } +export interface ClipboardWriteResult { + success: boolean; + error?: string; +} + /** * 检查剪贴板 API 是否可用 */ @@ -20,6 +25,17 @@ export function isClipboardApiAvailable(): boolean { ); } +/** + * 检查剪贴板写入 API 是否可用 + */ +export function isClipboardWriteAvailable(): boolean { + return ( + typeof navigator !== 'undefined' && + typeof navigator.clipboard !== 'undefined' && + typeof navigator.clipboard.writeText === 'function' + ); +} + /** * 检查是否在安全上下文中(HTTPS 或 localhost) */ @@ -105,6 +121,61 @@ export async function readClipboardText(): Promise { } } +/** + * 写入文本到剪贴板。 + * 优先使用 Clipboard API,失败时降级到 execCommand('copy')。 + */ +export async function writeClipboardText(text: string): Promise { + if (!text) { + return { + success: false, + error: '复制内容为空', + }; + } + + if (isSecureContext() && isClipboardWriteAvailable()) { + try { + await navigator.clipboard.writeText(text); + return { success: true }; + } catch { + // fallback to legacy way + } + } + + if (typeof document === 'undefined') { + return { + success: false, + error: '当前环境不支持复制', + }; + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (ok) { + return { success: true }; + } + return { + success: false, + error: '复制失败,请手动复制', + }; + } catch { + return { + success: false, + error: '复制失败,请手动复制', + }; + } +} + /** * 从粘贴事件中提取文件列表 */ diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 046feb5..f9a5ab8 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -37,7 +37,7 @@

加入房间

- 输入房间号,与同一房间的其他终端进行数据同步传输。 + 输入房间号和加入令牌,与同一房间的其他终端进行数据同步传输。

@@ -58,6 +58,20 @@ {{ joinError }}

+
+ + +
import { computed, ref } from 'vue'; import { useRouter } from 'vue-router'; -import axios from 'axios'; import BaseButton from '@/components/ui/BaseButton.vue'; +import { createRoom, verifyRoomAccess } from '@/api/room'; // 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问 const API_BASE = import.meta.env.VITE_API_BASE ?? ''; @@ -88,21 +102,23 @@ const createRoomCodeDisplay = ref('------'); const createLoading = ref(false); const createError = ref(''); const joinRoomCode = ref(''); +const joinToken = ref(''); const joinError = ref(''); -const isJoinValid = computed(() => /^[0-9]{6}$/.test(joinRoomCode.value)); +const isJoinValid = computed(() => { + return /^[0-9]{6}$/.test(joinRoomCode.value) && /^[A-Z0-9]{8}$/.test(joinToken.value); +}); async function handleCreateAndEnter() { createError.value = ''; createLoading.value = true; try { - const { data } = await axios.post<{ roomCode: string }>( - `${API_BASE}/api/room/create`, - ); + const data = await createRoom(API_BASE); const roomCode = data.roomCode; + const token = data.joinToken; createRoomCodeDisplay.value = roomCode; createLoading.value = false; - router.push({ name: 'room', params: { roomCode } }); + router.push({ name: 'room', params: { roomCode }, query: { token } }); } catch { createError.value = '创建房间失败,请稍后重试。'; createRoomCodeDisplay.value = String( @@ -112,13 +128,26 @@ async function handleCreateAndEnter() { } } -function handleJoin() { +function handleJoinTokenInput() { + joinToken.value = joinToken.value.toUpperCase().replace(/[^A-Z0-9]/g, ''); + joinError.value = ''; +} + +async function handleJoin() { joinError.value = ''; if (!isJoinValid.value) { - joinError.value = '房间号必须是 6 位数字。'; + joinError.value = '房间号为 6 位数字,加入令牌为 8 位字母数字。'; return; } - router.push({ name: 'room', params: { roomCode: joinRoomCode.value } }); + const ok = await verifyRoomAccess(joinRoomCode.value, joinToken.value, API_BASE); + if (!ok) { + joinError.value = '房间号或加入令牌错误。'; + return; + } + router.push({ + name: 'room', + params: { roomCode: joinRoomCode.value }, + query: { token: joinToken.value }, + }); } - diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue index ff84d52..506e29c 100644 --- a/frontend/src/views/RoomView.vue +++ b/frontend/src/views/RoomView.vue @@ -14,9 +14,11 @@ @@ -86,7 +92,7 @@