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

891 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数据传输助手 - 开发文档
## 拆分文档目录01/02/03…
- [01 - 整体架构与核心概念](docs/01-整体架构与核心概念.md)
- [02 - 房间管理(创建 / 加入 / 退出)](docs/02-房间管理(创建-加入-退出).md)
- [03 - WebSocket连接管理连接 / 订阅 / 心跳 / 重连)](docs/03-WebSocket连接管理连接-订阅-心跳-重连).md)
- [04 - 消息协议与消息分发统一Message模型](docs/04-消息协议与消息分发统一Message模型.md)
- [05 - 文本传输(含大文本分片)](docs/05-文本传输(含大文本分片).md)
- [06 - 文件传输(拖拽 / 分片 / 进度 / 下载)](docs/06-文件传输(拖拽-分片-进度-下载).md)
- [07 - 图片预览Base64内联显示](docs/07-图片预览Base64内联显示.md)
- [08 - 剪贴板集成(读取 / 粘贴 / 降级)](docs/08-剪贴板集成(读取-粘贴-降级).md)
- [09 - 在线用户列表(房间成员与系统消息)](docs/09-在线用户列表(房间成员与系统消息).md)
- [10 - 历史记录(本地存储 / 清空 / 导出)](docs/10-历史记录(本地存储-清空-导出).md)
- [11 - 数据库设计(可选:持久化与审计)](docs/11-数据库设计(可选:持久化与审计).md)
- [12 - 部署与环境配置(后端 / 前端 / Nginx](docs/12-部署与环境配置(后端-前端-Nginx.md)
- [13 - 安全与风控(基础措施与可选增强)](docs/13-安全与风控(基础措施与可选增强).md)
- [14 - 开发计划与里程碑Phase 1~4](docs/14-开发计划与里程碑Phase1-4.md)
## 一、项目概述
### 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 实体设计(可选,支持消息持久化)
```sql
-- 房间表
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 消息协议
#### 基础消息格式
```json
{
"type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK",
"senderId": "uuid",
"senderName": "用户昵称",
"timestamp": 1706345600000,
"payload": {}
}
```
#### 文本消息
```json
{
"type": "TEXT",
"senderId": "uuid",
"senderName": "用户A",
"timestamp": 1706345600000,
"payload": {
"content": "要传输的文本内容",
"isChunk": false,
"chunkIndex": 0,
"totalChunks": 1
}
}
```
#### 文件元数据消息
```json
{
"type": "FILE",
"senderId": "uuid",
"senderName": "用户A",
"timestamp": 1706345600000,
"payload": {
"fileId": "uuid",
"fileName": "document.pdf",
"fileSize": 2621440,
"mimeType": "application/pdf",
"totalChunks": 10
}
}
```
#### 文件分片消息
```json
{
"type": "CHUNK",
"senderId": "uuid",
"payload": {
"fileId": "uuid",
"chunkIndex": 0,
"data": "base64EncodedChunkData"
}
}
```
#### 系统消息
```json
{
"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
```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
```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
```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)
```javascript
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)
```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)
```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)
```yaml
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)
```javascript
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配置生产环境
```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)**
```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)**
```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 参考文档
- [Spring WebSocket官方文档](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket)
- [STOMP协议规范](https://stomp.github.io/)
- [Element Plus文档](https://element-plus.org/)
- [Vue 3文档](https://vuejs.org/)
### 11.2 常见问题
**Q: 文件传输大小限制?**
A: 默认100MB可通过配置调整。超大文件建议分片后逐片发送。
**Q: 支持多文件同时传输吗?**
A: 支持每个文件有独立fileId可并行传输。
**Q: 断线重连如何处理?**
STOMP客户端内置重连机制重连后需重新加入房间。
**Q: 内网环境如何部署?**
A: 打包后部署到内网服务器确保客户端能访问WebSocket端口。
---
**文档版本**: v1.0
**编写日期**: 2026-01-27
**作者**: AI Assistant