26 KiB
26 KiB
数据传输助手 - 开发文档
拆分文档目录(01/02/03…)
- 01 - 整体架构与核心概念
- 02 - 房间管理(创建 / 加入 / 退出)
- 03 - WebSocket连接管理(连接 / 订阅 / 心跳 / 重连)
- 04 - 消息协议与消息分发(统一Message模型)
- 05 - 文本传输(含大文本分片)
- 06 - 文件传输(拖拽 / 分片 / 进度 / 下载)
- 07 - 图片预览(Base64内联显示)
- 08 - 剪贴板集成(读取 / 粘贴 / 降级)
- 09 - 在线用户列表(房间成员与系统消息)
- 10 - 历史记录(本地存储 / 清空 / 导出)
- 11 - 数据库设计(可选:持久化与审计)
- 12 - 部署与环境配置(后端 / 前端 / Nginx)
- 13 - 安全与风控(基础措施与可选增强)
- 14 - 开发计划与里程碑(Phase 1~4)
一、项目概述
1.1 项目背景
在VNC远程桌面、内网环境或安全隔离场景中,由于剪贴板共享被禁用或系统限制,用户无法直接进行复制粘贴操作。本工具提供一个基于浏览器的轻量级数据传输方案,通过WebSocket实现多端实时数据同步。
1.2 应用场景
- VNC/远程桌面环境的数据传输
- 内外网隔离环境下的文件/文本交换
- 临时性的跨设备数据共享
- 无法使用U盘或即时通讯工具的场景
1.3 技术栈
| 层级 | 技术 | 版本建议 |
|---|---|---|
| 后端 | Spring Boot | 2.7.x / 3.x |
| 后端 | Java | 11 / 17 |
| 实时通信 | WebSocket (STOMP) | - |
| 前端 | Vue | 3.x |
| UI组件库 | Element Plus | 2.x |
| 构建工具 | Vite | 4.x |
二、系统架构
2.1 架构图
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 发送端浏览器 │◄───►│ WebSocket │◄───►│ 接收端浏览器 │
│ (Vue + Element)│ │ 服务器(Java) │ │ (Vue + Element)│
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┴───────────────────────┘
同一房间号(room)
2.2 核心概念
- 房间(Room):数据传输的隔离空间,通过6位数字房间号标识
- 会话(Session):WebSocket连接实例
- 消息(Message):传输的数据单元,支持文本、文件元数据、二进制分片
三、功能设计
3.1 功能模块
├── 房间管理
│ ├── 创建房间(自动生成6位房间号)
│ ├── 加入房间(输入房间号)
│ └── 退出房间
│
├── 数据传输
│ ├── 文本传输(支持大文本,自动分片)
│ ├── 文件传输(支持拖拽上传、进度显示)
│ ├── 图片预览(Base64内联显示)
│ └── 剪贴板读取(读取系统剪贴板内容)
│
├── 连接管理
│ ├── 在线用户列表
│ ├── 连接状态指示
│ └── 心跳保活
│
└── 历史记录
├── 消息历史(本地存储)
├── 清空记录
└── 导出记录
3.2 页面布局
┌─────────────────────────────────────────────────────────────┐
│ 数据传输助手 [连接状态] │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌───────────────────────────────────────┐ │
│ │ 房间信息 │ │ 消息展示区 │ │
│ │ ───────── │ │ ┌─────────────────────────────────┐ │ │
│ │ 房间号: │ │ │ [系统] xxx 加入了房间 │ │ │
│ │ 123456 │ │ │ [我] 这是一段文本消息... │ │ │
│ │ │ │ │ [对方] 收到,这是回复... │ │ │
│ │ ───────── │ │ │ [文件] 文档.pdf (2.5MB) [下载] │ │ │
│ │ 在线用户 │ │ └─────────────────────────────────┘ │ │
│ │ ● 用户A │ │ │ │
│ │ ○ 用户B │ └───────────────────────────────────────┘ │
│ │ │ ┌───────────────────────────────────────┐ │
│ │ ───────── │ │ [粘贴/拖拽区域] │ │
│ │ 快捷操作 │ │ 支持: 文本粘贴、文件拖拽、剪贴板读取 │ │
│ │ [清空] │ └───────────────────────────────────────┘ │
│ │ [导出] │ ┌────────────────────┐ ┌──────────────┐ │ │
│ └─────────────┘ │ 输入框... │ │ 发送 [▶] │ │ │
│ └────────────────────┘ └──────────────┘ │ │
└─────────────────────────────────────────────────────────────┘
四、数据库设计
4.1 实体设计(可选,支持消息持久化)
-- 房间表
CREATE TABLE room (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_code VARCHAR(6) UNIQUE NOT NULL COMMENT '房间号',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP COMMENT '过期时间',
is_active TINYINT DEFAULT 1
);
-- 消息记录表(可选,用于审计或消息回放)
CREATE TABLE message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_code VARCHAR(6) NOT NULL,
sender_id VARCHAR(64) NOT NULL,
sender_name VARCHAR(32),
msg_type TINYINT COMMENT '1-文本 2-文件 3-图片',
content TEXT COMMENT '文本内容或文件元数据JSON',
file_size BIGINT COMMENT '文件大小',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_room_time (room_code, created_at)
);
五、接口设计
5.1 WebSocket 端点
| 端点 | 说明 |
|---|---|
/ws/data-transfer |
WebSocket连接入口 |
/topic/room/{roomCode} |
房间广播频道(订阅) |
/app/room/{roomCode}/join |
加入房间(发送) |
/app/room/{roomCode}/leave |
离开房间(发送) |
/app/room/{roomCode}/message |
发送消息(发送) |
/app/room/{roomCode}/file/chunk |
发送文件分片(发送) |
5.2 消息协议
基础消息格式
{
"type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK",
"senderId": "uuid",
"senderName": "用户昵称",
"timestamp": 1706345600000,
"payload": {}
}
文本消息
{
"type": "TEXT",
"senderId": "uuid",
"senderName": "用户A",
"timestamp": 1706345600000,
"payload": {
"content": "要传输的文本内容",
"isChunk": false,
"chunkIndex": 0,
"totalChunks": 1
}
}
文件元数据消息
{
"type": "FILE",
"senderId": "uuid",
"senderName": "用户A",
"timestamp": 1706345600000,
"payload": {
"fileId": "uuid",
"fileName": "document.pdf",
"fileSize": 2621440,
"mimeType": "application/pdf",
"totalChunks": 10
}
}
文件分片消息
{
"type": "CHUNK",
"senderId": "uuid",
"payload": {
"fileId": "uuid",
"chunkIndex": 0,
"data": "base64EncodedChunkData"
}
}
系统消息
{
"type": "SYSTEM",
"payload": {
"event": "USER_JOIN | USER_LEAVE | ERROR",
"message": "xxx 加入了房间",
"userList": [{"id": "uuid", "name": "用户A"}]
}
}
六、后端实现
6.1 项目结构
data-transfer-server/
├── src/main/java/com/example/datatransfer/
│ ├── config/
│ │ ├── WebSocketConfig.java # WebSocket配置
│ │ └── CorsConfig.java # 跨域配置
│ ├── controller/
│ │ └── WebSocketController.java # WebSocket消息处理器
│ ├── service/
│ │ ├── RoomService.java # 房间管理
│ │ ├── MessageService.java # 消息处理
│ │ └── FileTransferService.java # 文件传输管理
│ ├── model/
│ │ ├── Message.java # 消息实体
│ │ ├── Room.java # 房间实体
│ │ └── FileChunk.java # 文件分片
│ ├── handler/
│ │ └── CustomHandshakeHandler.java # 握手处理器
│ └── DataTransferApplication.java
├── src/main/resources/
│ └── application.yml
└── pom.xml
6.2 核心代码示例
WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/data-transfer")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
WebSocketController.java
@Controller
public class WebSocketController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private RoomService roomService;
// 加入房间
@MessageMapping("/room/{roomCode}/join")
public void joinRoom(@DestinationVariable String roomCode,
@Payload JoinRequest request,
SimpMessageHeaderAccessor headerAccessor) {
String sessionId = headerAccessor.getSessionId();
roomService.joinRoom(roomCode, sessionId, request.getUserName());
// 广播用户加入消息
Message systemMsg = Message.builder()
.type(MessageType.SYSTEM)
.payload(Map.of(
"event", "USER_JOIN",
"message", request.getUserName() + " 加入了房间",
"userList", roomService.getUserList(roomCode)
))
.build();
messagingTemplate.convertAndSend("/topic/room/" + roomCode, systemMsg);
}
// 发送文本消息
@MessageMapping("/room/{roomCode}/message")
public void sendMessage(@DestinationVariable String roomCode,
@Payload Message message) {
message.setTimestamp(System.currentTimeMillis());
messagingTemplate.convertAndSend("/topic/room/" + roomCode, message);
}
// 发送文件分片
@MessageMapping("/room/{roomCode}/file/chunk")
public void sendFileChunk(@DestinationVariable String roomCode,
@Payload FileChunk chunk) {
messagingTemplate.convertAndSend(
"/topic/room/" + roomCode + "/file/" + chunk.getFileId(),
chunk
);
}
}
RoomService.java
@Service
public class RoomService {
// 内存存储,生产环境可改用Redis
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
public String createRoom() {
String roomCode = generateRoomCode();
Room room = new Room(roomCode);
rooms.put(roomCode, room);
return roomCode;
}
public void joinRoom(String roomCode, String sessionId, String userName) {
Room room = rooms.computeIfAbsent(roomCode, k -> new Room(k));
room.addUser(sessionId, userName);
}
public void leaveRoom(String roomCode, String sessionId) {
Room room = rooms.get(roomCode);
if (room != null) {
room.removeUser(sessionId);
if (room.isEmpty()) {
rooms.remove(roomCode);
}
}
}
private String generateRoomCode() {
// 生成6位数字房间号
Random random = new Random();
return String.format("%06d", random.nextInt(1000000));
}
}
七、前端实现
7.1 项目结构
data-transfer-web/
├── public/
├── src/
│ ├── api/
│ │ └── websocket.js # WebSocket封装
│ ├── components/
│ │ ├── RoomPanel.vue # 房间信息面板
│ │ ├── MessageList.vue # 消息列表
│ │ ├── MessageInput.vue # 消息输入
│ │ ├── FileDropZone.vue # 文件拖拽区域
│ │ └── UserList.vue # 在线用户列表
│ ├── views/
│ │ ├── HomeView.vue # 首页(创建/加入房间)
│ │ └── RoomView.vue # 房间页面
│ ├── stores/
│ │ └── room.js # Pinia状态管理
│ ├── utils/
│ │ ├── fileChunker.js # 文件分片工具
│ │ └── clipboard.js # 剪贴板工具
│ ├── App.vue
│ └── main.js
├── index.html
├── package.json
└── vite.config.js
7.2 核心代码示例
WebSocket封装 (websocket.js)
import SockJS from 'sockjs-client'
import { Client } from '@stomp/stompjs'
class WebSocketService {
constructor() {
this.client = null
this.subscriptions = new Map()
}
connect(url, onConnect, onError) {
this.client = new Client({
webSocketFactory: () => new SockJS(url),
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
})
this.client.onConnect = onConnect
this.client.onStompError = onError
this.client.activate()
}
subscribe(destination, callback) {
if (this.client && this.client.connected) {
const subscription = this.client.subscribe(destination, (message) => {
callback(JSON.parse(message.body))
})
this.subscriptions.set(destination, subscription)
}
}
send(destination, body) {
if (this.client && this.client.connected) {
this.client.publish({
destination,
body: JSON.stringify(body)
})
}
}
disconnect() {
this.subscriptions.forEach(sub => sub.unsubscribe())
this.subscriptions.clear()
if (this.client) {
this.client.deactivate()
}
}
}
export default new WebSocketService()
房间页面 (RoomView.vue)
<template>
<div class="room-container">
<el-container>
<!-- 左侧边栏 -->
<el-aside width="250px">
<RoomPanel
:roomCode="roomCode"
:userList="userList"
@leave="handleLeave"
/>
</el-aside>
<!-- 主内容区 -->
<el-main>
<MessageList
:messages="messages"
ref="messageList"
/>
<FileDropZone
@file-selected="handleFileSelect"
@text-pasted="handleTextPaste"
/>
<MessageInput
v-model="inputText"
@send="sendTextMessage"
@paste="handlePaste"
/>
</el-main>
</el-container>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import WebSocketService from '@/api/websocket'
import RoomPanel from '@/components/RoomPanel.vue'
import MessageList from '@/components/MessageList.vue'
import MessageInput from '@/components/MessageInput.vue'
import FileDropZone from '@/components/FileDropZone.vue'
const route = useRoute()
const router = useRouter()
const roomCode = route.params.code
const messages = ref([])
const userList = ref([])
const inputText = ref('')
const myId = ref('')
onMounted(() => {
connectWebSocket()
})
onUnmounted(() => {
WebSocketService.disconnect()
})
const connectWebSocket = () => {
WebSocketService.connect('/ws/data-transfer', () => {
// 订阅房间消息
WebSocketService.subscribe(`/topic/room/${roomCode}`, (msg) => {
handleMessage(msg)
})
// 加入房间
WebSocketService.send(`/app/room/${roomCode}/join`, {
userName: `用户${Math.random().toString(36).substr(2, 4)}`
})
})
}
const handleMessage = (msg) => {
switch (msg.type) {
case 'SYSTEM':
if (msg.payload.userList) {
userList.value = msg.payload.userList
}
messages.value.push({
type: 'system',
content: msg.payload.message
})
break
case 'TEXT':
messages.value.push({
type: msg.senderId === myId.value ? 'me' : 'other',
sender: msg.senderName,
content: msg.payload.content,
time: msg.timestamp
})
break
case 'FILE':
messages.value.push({
type: 'file',
sender: msg.senderName,
fileName: msg.payload.fileName,
fileSize: msg.payload.fileSize,
fileId: msg.payload.fileId
})
break
}
}
const sendTextMessage = () => {
if (!inputText.value.trim()) return
WebSocketService.send(`/app/room/${roomCode}/message`, {
type: 'TEXT',
senderId: myId.value,
payload: {
content: inputText.value,
isChunk: false
}
})
inputText.value = ''
}
const handleFileSelect = async (file) => {
// 发送文件元数据
const fileId = generateUUID()
const chunkSize = 64 * 1024 // 64KB分片
const totalChunks = Math.ceil(file.size / chunkSize)
WebSocketService.send(`/app/room/${roomCode}/message`, {
type: 'FILE',
senderId: myId.value,
payload: {
fileId,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
totalChunks
}
})
// 发送文件分片
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
const base64 = await fileToBase64(chunk)
WebSocketService.send(`/app/room/${roomCode}/file/chunk`, {
fileId,
chunkIndex: i,
data: base64.split(',')[1]
})
}
}
const handleTextPaste = (text) => {
inputText.value = text
}
const handleLeave = () => {
WebSocketService.send(`/app/room/${roomCode}/leave`, {})
router.push('/')
}
</script>
文件拖拽组件 (FileDropZone.vue)
<template>
<div
class="file-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop.prevent="handleDrop"
@paste="handlePaste"
tabindex="0"
>
<el-icon><Upload /></el-icon>
<span>拖拽文件到此处,或按 Ctrl+V 粘贴</span>
<el-button @click="readClipboard" size="small" type="primary">
读取剪贴板
</el-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const emit = defineEmits(['file-selected', 'text-pasted'])
const isDragOver = ref(false)
const handleDrop = (e) => {
isDragOver.value = false
const files = e.dataTransfer.files
if (files.length > 0) {
emit('file-selected', files[0])
}
}
const handlePaste = (e) => {
const items = e.clipboardData.items
for (let item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
emit('file-selected', file)
} else if (item.kind === 'string' && item.type === 'text/plain') {
item.getAsString(text => emit('text-pasted', text))
}
}
}
const readClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
emit('text-pasted', text)
ElMessage.success('已读取剪贴板内容')
} catch (err) {
ElMessage.error('无法读取剪贴板,请手动粘贴')
}
}
</script>
<style scoped>
.file-drop-zone {
border: 2px dashed #dcdfe6;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s;
margin: 10px 0;
}
.file-drop-zone.drag-over {
border-color: #409eff;
background: #f0f9ff;
}
</style>
八、部署配置
8.1 后端部署 (application.yml)
server:
port: 8080
spring:
websocket:
message-buffer-size: 8192
# 如需消息持久化,配置数据库
datasource:
url: jdbc:mysql://localhost:3306/data_transfer
username: root
password: xxx
# 文件传输配置
transfer:
chunk-size: 65536 # 64KB分片
max-file-size: 104857600 # 100MB
room-expire-hours: 24 # 房间过期时间
8.2 前端部署 (vite.config.js)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/ws': {
target: 'http://localhost:8080',
ws: true,
changeOrigin: true
},
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})
8.3 Nginx配置(生产环境)
server {
listen 80;
server_name your-domain.com;
location / {
root /path/to/data-transfer-web/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
九、安全考虑
9.1 安全措施
| 风险 | 解决方案 |
|---|---|
| 房间号暴力破解 | 6位数字+过期机制,限制尝试频率 |
| 大文件攻击 | 限制单文件100MB,限制房间总传输量 |
| XSS攻击 | 消息内容HTML转义,使用textContent |
| 数据泄露 | 房间自动过期,服务端不持久化敏感内容 |
| WebSocket劫持 | 握手时验证Origin,使用WSS加密 |
9.2 可选增强
- 添加房间密码
- 端到端加密(WebCrypto API)
- IP白名单限制
- 操作日志审计
十、开发计划
10.1 里程碑
| 阶段 | 时间 | 交付物 |
|---|---|---|
| Phase 1 | 3天 | 基础WebSocket连接、文本传输、房间管理 |
| Phase 2 | 3天 | 文件传输(分片)、进度显示、拖拽上传 |
| Phase 3 | 2天 | 剪贴板集成、图片预览、历史记录 |
| Phase 4 | 2天 | UI优化、响应式适配、测试修复 |
10.2 依赖清单
后端 (pom.xml)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
</dependencies>
前端 (package.json)
{
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"pinia": "^2.1.6",
"element-plus": "^2.3.14",
"@stomp/stompjs": "^7.0.0",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"vite": "^4.4.9",
"@vitejs/plugin-vue": "^4.3.4"
}
}
十一、附录
11.1 参考文档
11.2 常见问题
Q: 文件传输大小限制? A: 默认100MB,可通过配置调整。超大文件建议分片后逐片发送。
Q: 支持多文件同时传输吗? A: 支持,每个文件有独立fileId,可并行传输。
Q: 断线重连如何处理? STOMP客户端内置重连机制,重连后需重新加入房间。
Q: 内网环境如何部署? A: 打包后部署到内网服务器,确保客户端能访问WebSocket端口。
文档版本: v1.0
编写日期: 2026-01-27
作者: AI Assistant