Files
quick-share/docs/00-数据传输助手-开发文档.md

26 KiB
Raw Blame History

数据传输助手 - 开发文档

拆分文档目录01/02/03…

一、项目概述

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