Initial commit: DataTool backend, frontend and Docker
This commit is contained in:
890
docs/00-数据传输助手-开发文档.md
Normal file
890
docs/00-数据传输助手-开发文档.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 数据传输助手 - 开发文档
|
||||
|
||||
## 拆分文档目录(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
|
||||
Reference in New Issue
Block a user