diff --git a/.gitignore b/.gitignore index 58945fd..aab63b6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ *.dll *.so *.dylib - +.github/* # 测试二进制文件 *.test diff --git a/README.md b/README.md new file mode 100644 index 0000000..a692413 --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +# 川 - P2P文件传输系统 + +一个基于WebRTC技术的点对点文件传输系统,支持无服务器中转的直接文件传输。 + +## ✨ 功能特性 + +### 🚀 核心功能 +- **纯P2P传输**:文件直接在浏览器间传输,无需上传到服务器 +- **取件码机制**:6位随机取件码,简单易用 +- **实时传输**:支持大文件高速传输,64KB分块优化 +- **多文件支持**:可同时选择和传输多个文件 +- **拖拽上传**:支持文件拖拽选择 + +### 🛡️ 安全特性 +- **端到端加密**:WebRTC内置加密,数据传输安全 +- **临时连接**:传输完成后自动清理连接 +- **无文件存储**:服务器不存储任何文件内容 +- **房间隔离**:每个取件码对应独立的传输房间 + +### 🌐 技术特性 +- **WebRTC DataChannel**:高效的浏览器间直连 +- **国内STUN优化**:使用阿里云、腾讯云等国内STUN服务器 +- **断线重连**:WebSocket连接自动重连机制 +- **传输进度**:实时显示文件传输进度 +- **跨平台兼容**:支持现代浏览器(Chrome、Firefox、Safari、Edge) + +## 🏗️ 系统架构 + +``` +┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ 发送方浏览器 │◄────────┤ 信令服务器 ├────────►│ 接收方浏览器 │ +│ │ │ │ │ │ +│ ┌───────────┐ │ │ ┌──────────┐ │ │ ┌───────────┐ │ +│ │ 文件选择 │ │ │ │ WebSocket│ │ │ │ 取件码输入│ │ +│ └───────────┘ │ │ │ 信令 │ │ │ └───────────┘ │ +│ ┌───────────┐ │ │ └──────────┘ │ │ ┌───────────┐ │ +│ │ 生成取件码│ │ │ ┌──────────┐ │ │ │ 文件接收 │ │ +│ └───────────┘ │ │ │ 房间管理 │ │ │ └───────────┘ │ +│ │ │ └──────────┘ │ │ │ +└─────────────────┘ └──────────────┘ └─────────────────┘ + │ │ + └─────────────────── WebRTC P2P 连接 ──────────────────┘ + (文件直接传输) +``` + +## 🛠️ 技术栈 + +### 后端 +- **Go 1.21+**:高性能Web服务器 +- **Chi Router**:轻量级HTTP路由 +- **Gorilla WebSocket**:WebSocket连接管理 +- **标准库**:HTML模板、JSON处理等 + +### 前端 +- **原生JavaScript**:无框架依赖 +- **WebRTC API**:浏览器P2P通信 +- **Tailwind CSS**:现代化UI样式 +- **模块化设计**:分离的JS文件结构 + +### 基础设施 +- **Docker支持**:容器化部署 +- **Nginx代理**:生产环境反向代理 +- **STUN服务器**:NAT穿透支持 + +## 📁 项目结构 + +``` +chuan/ +├── cmd/ +│ └── main.go # 程序入口 +├── internal/ +│ ├── handlers/ +│ │ └── handlers.go # HTTP请求处理 +│ ├── models/ +│ │ └── models.go # 数据模型定义 +│ └── services/ +│ ├── file_service.go # 文件服务(预留) +│ ├── memory_store.go # 内存存储 +│ ├── p2p_service.go # P2P连接管理 +│ └── webrtc_service.go # WebRTC服务(预留) +├── web/ +│ ├── static/ +│ │ ├── css/ +│ │ │ └── style.css # 样式文件 +│ │ └── js/ +│ │ ├── common.js # 通用工具函数 +│ │ ├── p2p-transfer.js # P2P传输核心逻辑 +│ │ ├── webrtc-connection.js # WebRTC连接管理 +│ │ └── file-transfer.js # 文件传输处理 +│ └── templates/ +│ ├── base.html # 基础模板 +│ ├── index.html # 主页面 +│ ├── upload.html # 上传页面(预留) +│ └── video.html # 视频传输页面 +├── uploads/ # 上传目录(预留) +├── bin/ +│ └── chuan # 编译后的可执行文件 +├── docker-compose.yml # Docker Compose配置 +├── Dockerfile # Docker镜像构建 +├── nginx.conf # Nginx配置 +├── Makefile # 构建脚本 +├── deploy.sh # 部署脚本 +├── go.mod # Go模块定义 +├── go.sum # Go模块校验 +└── README.md # 项目文档 +``` + +## 🚀 快速开始 + +### 本地开发 + +1. **克隆项目** +```bash +git clone +cd chuan +``` + +2. **安装依赖** +```bash +go mod tidy +``` + +3. **启动服务** +```bash +go run cmd/main.go +``` + +4. **访问应用** +``` +打开浏览器访问: http://localhost:8080 +``` + +### 使用Make命令 + +```bash +# 运行开发服务器 +make run + +# 构建可执行文件 +make build + +# 清理构建文件 +make clean + +# 运行测试 +make test +``` + +### Docker部署 + +1. **构建镜像** +```bash +docker build -t chuan . +``` + +2. **运行容器** +```bash +docker run -p 8080:8080 chuan +``` + +3. **使用Docker Compose** +```bash +docker-compose up -d +``` + +## 📖 使用说明 + +### 发送文件 + +1. 访问主页面 +2. 点击选择文件或拖拽文件到上传区域 +3. 点击"生成取件码"按钮 +4. 分享6位取件码给接收方 +5. 等待接收方连接并开始传输 + +### 接收文件 + +1. 访问主页面 +2. 在"输入取件码"区域输入6位取件码 +3. 系统自动连接并显示文件列表 +4. 等待P2P连接建立(显示绿色状态) +5. 点击"下载"按钮开始接收文件 + +### 传输状态说明 + +- 🟡 **等待连接**:正在建立WebSocket连接 +- 🟢 **P2P已连接**:可以开始文件传输 +- 🔴 **连接失败**:请检查网络或重试 + +## ⚙️ 配置说明 + +### 环境变量 + +- `PORT`:服务器端口(默认8080) +- `HOST`:服务器主机(默认localhost) + +### STUN服务器配置 + +系统默认使用以下STUN服务器(按优先级): + +1. `stun:stun.chat.bilibili.com:3478` - 哔哩哔哩 +2. `stun:stun.voipbuster.com` - VoIP服务 +3. `stun:stun.voipstunt.com` - VoIP服务 +4. `stun:stun.qq.com:3478` - 腾讯QQ +5. `stun:stun.l.google.com:19302` - Google(备用) + +## 🔧 高级配置 + +### 传输参数优化 + +在 `file-transfer.js` 中可调整以下参数: + +```javascript +const chunkSize = 65536; // 分块大小(64KB) +const transmissionDelay = 1; // 传输间隔(1ms) +const maxRetransmits = 3; // 最大重传次数 +const maxPacketLifeTime = 3000; // 数据包最大生存时间 +``` + +### 连接超时设置 + +在 `webrtc-connection.js` 中可调整: + +```javascript +const connectionTimeout = 60000; // 连接超时(60秒) +``` + +## 🐛 故障排除 + +### 常见问题 + +1. **P2P连接失败** + - 检查防火墙设置 + - 确认浏览器支持WebRTC + - 尝试使用不同的STUN服务器 + +2. **文件传输中断** + - 检查网络连接稳定性 + - 避免浏览器标签页切换到后台 + - 大文件传输建议分批进行 + +3. **取件码无效** + - 确认取件码输入正确(6位大写字母数字) + - 检查房间是否已过期(默认1小时) + - 确认发送方仍在线 + +### 调试模式 + +打开浏览器开发者工具(F12),查看Console标签页获取详细日志信息。 + +## 🚀 生产部署 + +### Nginx配置 + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + 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_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + 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; + } +} +``` + +### systemd服务 + +创建 `/etc/systemd/system/chuan.service`: + +```ini +[Unit] +Description=Chuan P2P File Transfer Service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/chuan +ExecStart=/opt/chuan/bin/chuan +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +启动服务: +```bash +sudo systemctl enable chuan +sudo systemctl start chuan +``` + +## 🤝 贡献指南 + +1. Fork 项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +## 📝 开发计划 + +### v1.1 计划功能 +- [ ] 文件传输加密增强 +- [ ] 传输速度优化 +- [ ] 移动端适配改进 +- [ ] 批量文件操作 + +### v1.2 计划功能 +- [ ] 用户认证系统 +- [ ] 传输历史记录 +- [ ] 文件预览功能 +- [ ] API接口开放 + +### v2.0 计划功能 +- [ ] 视频通话功能完善 +- [ ] 屏幕共享支持 +- [ ] 多人会议室 +- [ ] 云存储集成 + +## 📄 许可证 + +本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情 + +## 🙏 致谢 + +- [WebRTC](https://webrtc.org/) - 实时通信技术 +- [Go](https://golang.org/) - 后端开发语言 +- [Tailwind CSS](https://tailwindcss.com/) - UI样式框架 +- [Chi Router](https://go-chi.io/) - Go HTTP路由库 + +## 📞 联系方式 + +- 项目主页:[GitHub Repository] +- 问题反馈:[GitHub Issues] + +--- + +⭐ 如果这个项目对你有帮助,请给它一个星标! diff --git a/cmd/main.go b/cmd/main.go index 42de9c1..54930fa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -53,6 +53,8 @@ func main() { // API路由 r.Post("/api/create-room", h.CreateRoomHandler) r.Get("/api/room-info", h.GetRoomInfoHandler) + r.Get("/api/room-status", h.GetRoomStatusHandler) + r.Post("/api/update-room-files", h.UpdateRoomFilesHandler) // 启动服务器 srv := &http.Server{ diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 706bd2f..026dc75 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -123,6 +123,69 @@ func (h *Handler) GetRoomInfoHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// GetRoomStatusHandler 获取房间状态API +func (h *Handler) GetRoomStatusHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "缺少取件码", http.StatusBadRequest) + return + } + + status, exists := h.p2pService.GetRoomStatusByCode(code) + if !exists { + response := map[string]interface{}{ + "success": false, + "message": "取件码不存在或已过期", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + response := map[string]interface{}{ + "success": true, + "status": status, + "message": "房间状态获取成功", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// UpdateRoomFilesHandler 更新房间文件列表API +func (h *Handler) UpdateRoomFilesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + var req struct { + Code string `json:"code"` + Files []models.FileTransferInfo `json:"files"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败", http.StatusBadRequest) + return + } + + // 更新房间文件列表 + success := h.p2pService.UpdateRoomFiles(req.Code, req.Files) + + response := map[string]interface{}{ + "success": success, + } + + if success { + response["message"] = "文件列表更新成功" + } else { + response["message"] = "房间不存在或更新失败" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + // HandleP2PWebSocket 处理P2P WebSocket连接 func (h *Handler) HandleP2PWebSocket(w http.ResponseWriter, r *http.Request) { h.p2pService.HandleWebSocket(w, r) diff --git a/internal/models/models.go b/internal/models/models.go index 98e55d1..9c13499 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -2,6 +2,8 @@ package models import ( "time" + + "github.com/gorilla/websocket" ) // FileInfo 文件信息结构 @@ -60,6 +62,25 @@ type FileTransferInfo struct { LastModified int64 `json:"lastModified"` } +// ClientInfo 客户端连接信息 +type ClientInfo struct { + ID string `json:"id"` // 客户端唯一标识 + Role string `json:"role"` // sender 或 receiver + Connection *websocket.Conn `json:"-"` // WebSocket连接(不序列化) + JoinedAt time.Time `json:"joined_at"` // 加入时间 + UserAgent string `json:"user_agent"` // 用户代理 +} + +// RoomStatus 房间状态信息 +type RoomStatus struct { + Code string `json:"code"` + FileCount int `json:"file_count"` + SenderCount int `json:"sender_count"` + ReceiverCount int `json:"receiver_count"` + Clients []ClientInfo `json:"clients"` + CreatedAt time.Time `json:"created_at"` +} + // ErrorResponse 错误响应结构 type ErrorResponse struct { Success bool `json:"success"` diff --git a/internal/services/p2p_service.go b/internal/services/p2p_service.go index ab47362..5224220 100644 --- a/internal/services/p2p_service.go +++ b/internal/services/p2p_service.go @@ -1,8 +1,10 @@ package services import ( + "crypto/rand" + "fmt" "log" - "math/rand" + mathrand "math/rand" "net/http" "strconv" "sync" @@ -15,11 +17,10 @@ import ( type FileTransferRoom struct { ID string - Code string // 取件码 - Files []models.FileTransferInfo // 待传输文件信息 - Sender *websocket.Conn // 发送方连接 - Receiver *websocket.Conn // 接收方连接 - CreatedAt time.Time // 创建时间 + Code string // 取件码 + Files []models.FileTransferInfo // 待传输文件信息 + Clients map[string]*models.ClientInfo // 所有连接的客户端 (客户端ID -> ClientInfo) + CreatedAt time.Time // 创建时间 mutex sync.RWMutex } @@ -57,6 +58,7 @@ func (p *P2PService) CreateRoom(files []models.FileTransferInfo) string { ID: "room_" + code, Code: code, Files: files, + Clients: make(map[string]*models.ClientInfo), CreatedAt: time.Now(), } @@ -75,6 +77,13 @@ func (p *P2PService) GetRoomByCode(code string) (*FileTransferRoom, bool) { return room, exists } +// generateClientID 生成客户端唯一标识 +func generateClientID() string { + b := make([]byte, 8) + rand.Read(b) + return fmt.Sprintf("client_%x", b) +} + // HandleWebSocket 处理WebSocket连接 func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := p.upgrader.Upgrade(w, r, nil) @@ -100,16 +109,23 @@ func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { return } - // 设置连接 - room.mutex.Lock() - if role == "sender" { - room.Sender = conn - log.Printf("发送方连接到房间: %s", code) - } else { - room.Receiver = conn - log.Printf("接收方连接到房间: %s", code) + // 生成客户端ID并创建客户端信息 + clientID := generateClientID() + client := &models.ClientInfo{ + ID: clientID, + Role: role, + Connection: conn, + JoinedAt: time.Now(), + UserAgent: r.Header.Get("User-Agent"), + } - // 发送文件列表给接收方 + // 将客户端加入房间 + room.mutex.Lock() + room.Clients[clientID] = client + log.Printf("%s连接到房间: %s (客户端ID: %s)", role, code, clientID) + + // 如果是接收方,发送文件列表 + if role == "receiver" { filesMsg := models.VideoMessage{ Type: "file-list", Payload: map[string]interface{}{"files": room.Files}, @@ -118,28 +134,49 @@ func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { log.Printf("发送文件列表失败: %v", err) } - // 通知发送方接收方已连接 - if room.Sender != nil { - readyMsg := models.VideoMessage{ - Type: "receiver-ready", - Payload: map[string]interface{}{}, - } - if err := room.Sender.WriteJSON(readyMsg); err != nil { - log.Printf("发送接收方就绪消息失败: %v", err) - } - } + // 通知所有发送方有新的接收方加入 + p.notifyClients(room, "sender", models.VideoMessage{ + Type: "new-receiver", + Payload: map[string]interface{}{ + "client_id": clientID, + "joined_at": client.JoinedAt, + }, + }) + } else if role == "sender" { + // 通知所有接收方有新的发送方加入 + p.notifyClients(room, "receiver", models.VideoMessage{ + Type: "new-sender", + Payload: map[string]interface{}{ + "client_id": clientID, + "joined_at": client.JoinedAt, + }, + }) } - room.mutex.Unlock() // 连接关闭时清理 + + // 发送房间状态给所有客户端 + p.broadcastRoomStatus(room) + room.mutex.Unlock() + + // 连接关闭时清理 defer func() { room.mutex.Lock() - if role == "sender" { - room.Sender = nil - } else { - room.Receiver = nil - } + delete(room.Clients, clientID) + log.Printf("客户端断开连接: %s (房间: %s)", clientID, code) + + // 通知其他客户端有人离开 + p.notifyClients(room, "", models.VideoMessage{ + Type: "client-left", + Payload: map[string]interface{}{ + "client_id": clientID, + "role": role, + }, + }) + + // 发送更新后的房间状态 + p.broadcastRoomStatus(room) room.mutex.Unlock() - // 如果双方都断开连接,删除房间 + // 如果房间没有客户端了,清理房间 p.cleanupRoom(code) }() @@ -152,37 +189,146 @@ func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { break } - log.Printf("收到WebSocket消息: 类型=%s, 来自=%s, 房间=%s", msg.Type, role, code) + log.Printf("收到WebSocket消息: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code) - // 转发消息到对方 - p.forwardMessage(room, role, msg) + // 处理特殊消息类型 + switch msg.Type { + case "file-request": + // 处理文件请求 + p.handleFileRequest(room, clientID, msg) + case "file-info", "file-chunk", "file-complete": + // 处理文件传输相关消息,直接转发给接收方 + p.forwardMessage(room, clientID, msg) + default: + // 转发消息到对应的客户端 + p.forwardMessage(room, clientID, msg) + } } } -// forwardMessage 转发消息到对方 -func (p *P2PService) forwardMessage(room *FileTransferRoom, senderRole string, msg models.VideoMessage) { +// notifyClients 通知指定角色的客户端 +func (p *P2PService) notifyClients(room *FileTransferRoom, role string, msg models.VideoMessage) { + for _, client := range room.Clients { + if role == "" || client.Role == role { + if err := client.Connection.WriteJSON(msg); err != nil { + log.Printf("发送消息到客户端失败 %s: %v", client.ID, err) + } + } + } +} + +// broadcastRoomStatus 广播房间状态给所有客户端 +func (p *P2PService) broadcastRoomStatus(room *FileTransferRoom) { + status := p.getRoomStatus(room) + statusMsg := models.VideoMessage{ + Type: "room-status", + Payload: status, + } + + for _, client := range room.Clients { + if err := client.Connection.WriteJSON(statusMsg); err != nil { + log.Printf("发送房间状态失败 %s: %v", client.ID, err) + } + } +} + +// getRoomStatus 获取房间状态 +func (p *P2PService) getRoomStatus(room *FileTransferRoom) models.RoomStatus { + senderCount := 0 + receiverCount := 0 + clients := make([]models.ClientInfo, 0, len(room.Clients)) + + for _, client := range room.Clients { + // 创建不包含连接的客户端信息副本 + clientCopy := models.ClientInfo{ + ID: client.ID, + Role: client.Role, + JoinedAt: client.JoinedAt, + UserAgent: client.UserAgent, + } + clients = append(clients, clientCopy) + + if client.Role == "sender" { + senderCount++ + } else if client.Role == "receiver" { + receiverCount++ + } + } + + return models.RoomStatus{ + Code: room.Code, + FileCount: len(room.Files), + SenderCount: senderCount, + ReceiverCount: receiverCount, + Clients: clients, + CreatedAt: room.CreatedAt, + } +} + +// handleFileRequest 处理文件请求 +func (p *P2PService) handleFileRequest(room *FileTransferRoom, clientID string, msg models.VideoMessage) { + // 获取请求的文件ID + payload, ok := msg.Payload.(map[string]interface{}) + if !ok { + log.Printf("无效的文件请求消息格式") + return + } + + fileID, ok := payload["file_id"].(string) + if !ok { + log.Printf("缺少文件ID") + return + } + + // 转发文件请求给所有发送方 + requestMsg := models.VideoMessage{ + Type: "file-request", + Payload: map[string]interface{}{ + "file_id": fileID, + "requester": clientID, + "request_id": payload["request_id"], + }, + } + + p.notifyClients(room, "sender", requestMsg) +} + +// forwardMessage 转发消息到指定客户端或所有对应角色的客户端 +func (p *P2PService) forwardMessage(room *FileTransferRoom, senderClientID string, msg models.VideoMessage) { room.mutex.RLock() defer room.mutex.RUnlock() - var targetConn *websocket.Conn - var targetRole string - if senderRole == "sender" && room.Receiver != nil { - targetConn = room.Receiver + senderClient, exists := room.Clients[senderClientID] + if !exists { + log.Printf("发送方客户端不存在: %s", senderClientID) + return + } + + // 检查消息是否指定了目标客户端 + if payload, ok := msg.Payload.(map[string]interface{}); ok { + if targetID, hasTarget := payload["target_client"].(string); hasTarget { + // 发送给指定客户端 + if targetClient, exists := room.Clients[targetID]; exists { + log.Printf("转发消息: 类型=%s, 从%s到%s", msg.Type, senderClientID, targetID) + if err := targetClient.Connection.WriteJSON(msg); err != nil { + log.Printf("转发消息失败: %v", err) + } + return + } + } + } + + // 否则根据角色转发给对应的客户端 + targetRole := "" + if senderClient.Role == "sender" { targetRole = "receiver" - } else if senderRole == "receiver" && room.Sender != nil { - targetConn = room.Sender + } else if senderClient.Role == "receiver" { targetRole = "sender" } - if targetConn != nil { - log.Printf("转发消息: 类型=%s, 从%s到%s", msg.Type, senderRole, targetRole) - if err := targetConn.WriteJSON(msg); err != nil { - log.Printf("转发消息失败: %v", err) - } else { - log.Printf("消息转发成功: 类型=%s", msg.Type) - } - } else { - log.Printf("无法转发消息: 目标连接不存在, 发送方=%s", senderRole) + if targetRole != "" { + log.Printf("广播消息: 类型=%s, 从%s到所有%s", msg.Type, senderClient.Role, targetRole) + p.notifyClients(room, targetRole, msg) } } @@ -193,10 +339,10 @@ func (p *P2PService) cleanupRoom(code string) { if room, exists := p.rooms[code]; exists { room.mutex.RLock() - bothDisconnected := room.Sender == nil && room.Receiver == nil + noClients := len(room.Clients) == 0 room.mutex.RUnlock() - if bothDisconnected { + if noClients { delete(p.rooms, code) log.Printf("清理房间: %s", code) } @@ -224,12 +370,27 @@ func (p *P2PService) cleanupExpiredRooms() { // generatePickupCode 生成6位取件码 func generatePickupCode() string { - rand.Seed(time.Now().UnixNano()) - code := rand.Intn(900000) + 100000 + mathrand.Seed(time.Now().UnixNano()) + code := mathrand.Intn(900000) + 100000 return strconv.Itoa(code) } -// GetRoomStats 获取房间统计信息 +// GetRoomStatusByCode 根据取件码获取房间状态 +func (p *P2PService) GetRoomStatusByCode(code string) (models.RoomStatus, bool) { + p.roomsMux.RLock() + defer p.roomsMux.RUnlock() + + room, exists := p.rooms[code] + if !exists { + return models.RoomStatus{}, false + } + + room.mutex.RLock() + status := p.getRoomStatus(room) + room.mutex.RUnlock() + + return status, true +} func (p *P2PService) GetRoomStats() map[string]interface{} { p.roomsMux.RLock() defer p.roomsMux.RUnlock() @@ -241,12 +402,14 @@ func (p *P2PService) GetRoomStats() map[string]interface{} { for code, room := range p.rooms { room.mutex.RLock() + status := p.getRoomStatus(room) roomInfo := map[string]interface{}{ - "code": code, - "file_count": len(room.Files), - "has_sender": room.Sender != nil, - "has_receiver": room.Receiver != nil, - "created_at": room.CreatedAt, + "code": code, + "file_count": len(room.Files), + "sender_count": status.SenderCount, + "receiver_count": status.ReceiverCount, + "total_clients": len(room.Clients), + "created_at": room.CreatedAt, } room.mutex.RUnlock() stats["rooms"] = append(stats["rooms"].([]map[string]interface{}), roomInfo) @@ -254,3 +417,40 @@ func (p *P2PService) GetRoomStats() map[string]interface{} { return stats } + +// UpdateRoomFiles 更新房间文件列表 +func (p *P2PService) UpdateRoomFiles(code string, files []models.FileTransferInfo) bool { + p.roomsMux.RLock() + room, exists := p.rooms[code] + p.roomsMux.RUnlock() + + if !exists { + return false + } + + room.mutex.Lock() + room.Files = files + room.mutex.Unlock() + + log.Printf("房间 %s 文件列表已更新,共 %d 个文件", code, len(files)) + + // 通知所有连接的客户端文件列表已更新 + room.mutex.RLock() + for _, client := range room.Clients { + if client.Role == "receiver" { + message := models.VideoMessage{ + Type: "file-list-updated", + Payload: map[string]interface{}{ + "files": files, + }, + } + + if err := client.Connection.WriteJSON(message); err != nil { + log.Printf("发送文件列表更新消息失败: %v", err) + } + } + } + room.mutex.RUnlock() + + return true +} diff --git a/web/static/js/p2p-transfer-new.js b/web/static/js/p2p-transfer-new.js new file mode 100644 index 0000000..fee7d09 --- /dev/null +++ b/web/static/js/p2p-transfer-new.js @@ -0,0 +1,1005 @@ +// P2P文件传输系统(多人房间版本) +// 全局变量 +let websocket = null; +let clientConnections = new Map(); // 存储与其他客户端的P2P连接 +let selectedFiles = []; +let currentPickupCode = ''; +let currentRole = ''; // 'sender' or 'receiver' +let currentClientId = ''; // 当前客户端ID +let fileTransfers = new Map(); // 存储文件传输状态 +let isP2PConnected = false; // P2P连接状态 +let isConnecting = false; // 是否正在连接中 +let pendingChunkMeta = null; // 待处理的数据块元数据 + +// 页面加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + initializeEventListeners(); +}); + +// 初始化事件监听器 +function initializeEventListeners() { + // 文件选择事件 + document.getElementById('fileInput').addEventListener('change', handleFileSelect); + + // 取件码输入事件 + document.getElementById('pickupCodeInput').addEventListener('input', (e) => { + e.target.value = e.target.value.toUpperCase(); + if (e.target.value.length === 6) { + // 自动连接 + setTimeout(() => joinRoom(), 100); + } + }); + + // 拖拽上传 + setupDragAndDrop(); +} + +// 设置拖拽上传 +function setupDragAndDrop() { + const dropArea = document.querySelector('.border-dashed'); + dropArea.addEventListener('dragover', (e) => { + e.preventDefault(); + dropArea.classList.add('border-blue-400'); + }); + + dropArea.addEventListener('dragleave', () => { + dropArea.classList.remove('border-blue-400'); + }); + + dropArea.addEventListener('drop', (e) => { + e.preventDefault(); + dropArea.classList.remove('border-blue-400'); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + // 添加新文件到现有列表 + selectedFiles = [...selectedFiles, ...files]; + displaySelectedFiles(); + + // 如果已经生成了取件码,自动更新房间文件列表 + if (currentPickupCode && currentRole === 'sender') { + updateRoomFiles(); + } + } + }); +} + +// 处理文件选择 +function handleFileSelect(event) { + const files = Array.from(event.target.files); + if (files.length > 0) { + // 添加新文件到现有列表 + selectedFiles = [...selectedFiles, ...files]; + displaySelectedFiles(); + + // 如果已经生成了取件码,自动更新房间文件列表 + if (currentPickupCode && currentRole === 'sender') { + updateRoomFiles(); + } + } +} + +// 显示选中的文件 +function displaySelectedFiles() { + const container = document.getElementById('selectedFiles'); + const filesList = document.getElementById('filesList'); + + if (selectedFiles.length === 0) { + container.classList.add('hidden'); + return; + } + + container.classList.remove('hidden'); + filesList.innerHTML = ''; + + selectedFiles.forEach((file, index) => { + const fileItem = document.createElement('div'); + fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg'; + fileItem.innerHTML = ` +
+ ${getFileIcon(file.type)} +
+
${file.name}
+
${formatFileSize(file.size)}
+
+
+ + `; + filesList.appendChild(fileItem); + }); +} + +// 移除文件 +function removeFile(index) { + selectedFiles.splice(index, 1); + displaySelectedFiles(); + + // 如果已经生成了取件码,需要更新房间文件列表 + if (currentPickupCode && currentRole === 'sender') { + updateRoomFiles(); + } +} + +// 添加更多文件 +function addMoreFiles() { + document.getElementById('fileInput').click(); +} + +// 更新房间文件列表 +async function updateRoomFiles() { + if (!currentPickupCode || currentRole !== 'sender') return; + + const fileInfos = selectedFiles.map((file, index) => ({ + id: 'file_' + index, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + })); + + try { + const response = await fetch('/api/update-room-files', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code: currentPickupCode, + files: fileInfos + }) + }); + + const data = await response.json(); + if (data.success) { + console.log('房间文件列表已更新'); + showNotification('文件列表已更新', 'success'); + + // 通过WebSocket通知所有接收方文件列表更新 + if (websocket && websocket.readyState === WebSocket.OPEN) { + const updateMsg = { + type: 'file-list-updated', + payload: { + files: fileInfos + } + }; + websocket.send(JSON.stringify(updateMsg)); + } + } else { + console.error('更新文件列表失败:', data.message); + showNotification('更新文件列表失败: ' + data.message, 'error'); + } + } catch (error) { + console.error('更新文件列表请求失败:', error); + showNotification('更新文件列表失败,请重试', 'error'); + } +} + +// 生成取件码 +async function generatePickupCode() { + if (selectedFiles.length === 0) return; + + // 准备文件信息 + const fileInfos = selectedFiles.map((file, index) => ({ + id: 'file_' + index, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified + })); + + try { + const response = await fetch('/api/create-room', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ files: fileInfos }) + }); + + const data = await response.json(); + if (data.success) { + currentPickupCode = data.code; + currentRole = 'sender'; + showPickupCode(data.code); + connectWebSocket(); + } else { + alert('生成取件码失败: ' + data.message); + } + } catch (error) { + console.error('生成取件码失败:', error); + alert('生成取件码失败,请重试'); + } +} + +// 显示取件码 +function showPickupCode(code) { + document.getElementById('pickupCodeDisplay').textContent = code; + document.getElementById('pickupCodeSection').classList.remove('hidden'); + // 不隐藏生成取件码按钮,改为"添加更多文件" + const generateBtn = document.getElementById('generateCodeBtn'); + generateBtn.textContent = '➕ 添加更多文件'; + generateBtn.onclick = addMoreFiles; +} + +// 复制取件码 +function copyPickupCode() { + navigator.clipboard.writeText(currentPickupCode).then(() => { + alert('取件码已复制到剪贴板'); + }); +} + +// 重置发送方 +function resetSender() { + selectedFiles = []; + currentPickupCode = ''; + currentRole = ''; + currentClientId = ''; + if (websocket) { + websocket.close(); + } + + document.getElementById('selectedFiles').classList.add('hidden'); + document.getElementById('pickupCodeSection').classList.add('hidden'); + document.getElementById('generateCodeBtn').classList.remove('hidden'); + document.getElementById('fileInput').value = ''; + document.getElementById('roomStatusSection').classList.add('hidden'); +} + +// 加入房间 +async function joinRoom() { + const code = document.getElementById('pickupCodeInput').value.trim(); + if (code.length !== 6) { + alert('请输入6位取件码'); + return; + } + + try { + const response = await fetch(`/api/room-info?code=${code}`); + const data = await response.json(); + + if (data.success) { + currentPickupCode = code; + currentRole = 'receiver'; + displayReceiverFiles(data.files); + connectWebSocket(); + } else { + alert(data.message); + } + } catch (error) { + console.error('连接失败:', error); + alert('连接失败,请检查取件码是否正确'); + } +} + +// WebSocket连接函数 +function connectWebSocket() { + console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode); + + if (!currentPickupCode || !currentRole) { + console.error('缺少必要参数:取件码或角色'); + showNotification('连接参数错误', 'error'); + return; + } + + if (isConnecting) { + console.log('已在连接中,跳过'); + return; + } + + isConnecting = true; + + // 如果已经有连接,先关闭 + if (websocket) { + console.log('关闭现有WebSocket连接'); + websocket.close(); + websocket = null; + } + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${currentPickupCode}&role=${currentRole}`; + console.log('WebSocket URL:', wsUrl); + + try { + websocket = new WebSocket(wsUrl); + + websocket.onopen = () => { + console.log('WebSocket连接已建立, 当前角色:', currentRole); + isConnecting = false; + updateConnectionStatus(true); + + // 连接建立后,启用P2P功能 + if (currentRole === 'receiver') { + console.log('接收方WebSocket连接成功,启用下载功能'); + updateP2PStatus(true); // 接收方连接成功后立即启用下载 + showNotification('连接成功,可以开始下载文件', 'success'); + } + + // 发送方在WebSocket连接建立后显示房间状态 + if (currentRole === 'sender') { + console.log('发送方初始化完成'); + showRoomStatus(); + } + }; + + websocket.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + console.log('收到WebSocket消息:', message); + await handleWebSocketMessage(message); + } catch (error) { + console.error('解析WebSocket消息失败:', error, event.data); + } + }; + + websocket.onerror = (error) => { + console.error('WebSocket错误:', error); + isConnecting = false; + updateConnectionStatus(false); + updateP2PStatus(false); + showNotification('WebSocket连接失败,请检查网络连接', 'error'); + }; + + websocket.onclose = (event) => { + console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason); + isConnecting = false; + updateConnectionStatus(false); + updateP2PStatus(false); + websocket = null; + + // 如果不是正常关闭且还需要连接,尝试重连 + if (event.code !== 1000 && currentPickupCode && !isConnecting) { + console.log('WebSocket异常关闭,5秒后尝试重连'); + showNotification('连接断开,5秒后自动重连...', 'info'); + setTimeout(() => { + if (currentPickupCode && !websocket && !isConnecting) { + console.log('尝试重新连接WebSocket'); + connectWebSocket(); + } + }, 5000); + } + }; + + // 设置连接超时 + setTimeout(() => { + if (websocket && websocket.readyState === WebSocket.CONNECTING) { + console.log('WebSocket连接超时'); + websocket.close(); + showNotification('连接超时,请重试', 'error'); + } + }, 10000); + + } catch (error) { + console.error('创建WebSocket连接失败:', error); + isConnecting = false; + showNotification('无法创建WebSocket连接: ' + error.message, 'error'); + } +} + +// 处理WebSocket消息 +async function handleWebSocketMessage(message) { + console.log('处理WebSocket消息:', message.type, message); + + switch (message.type) { + case 'file-list': + // 接收到文件列表 + if (currentRole === 'receiver') { + displayReceiverFiles(message.payload.files); + } + break; + + case 'file-list-updated': + // 文件列表更新(通知接收方) + if (currentRole === 'receiver') { + console.log('收到文件列表更新通知'); + displayReceiverFiles(message.payload.files); + showNotification('文件列表已更新,发现新文件!', 'info'); + } + break; + + case 'room-status': + // 房间状态更新 + updateRoomStatus(message.payload); + break; + + case 'new-receiver': + // 新接收方加入 + if (currentRole === 'sender') { + console.log('新接收方加入:', message.payload.client_id); + showNotification('有新用户加入房间', 'info'); + } + break; + + case 'new-sender': + // 新发送方加入 + if (currentRole === 'receiver') { + console.log('新发送方加入:', message.payload.client_id); + } + break; + + case 'client-left': + // 客户端离开 + console.log('客户端离开:', message.payload.client_id, message.payload.role); + break; + + case 'file-request': + // 文件请求 + if (currentRole === 'sender') { + await handleFileRequest(message.payload); + } + break; + + case 'file-info': + // 文件信息(接收方) + if (currentRole === 'receiver') { + initFileTransfer(message.payload); + } + break; + + case 'file-chunk': + // 文件数据块(接收方) + if (currentRole === 'receiver') { + receiveFileChunk(message.payload); + } + break; + + case 'file-complete': + // 文件传输完成(接收方) + if (currentRole === 'receiver') { + completeFileDownload(message.payload.file_id); + } + break; + + default: + console.log('未知消息类型:', message.type); + } +} + +// 更新连接状态 +function updateConnectionStatus(connected) { + const senderStatus = document.getElementById('senderStatus'); + const receiverStatus = document.getElementById('receiverStatus'); + + if (currentRole === 'sender' && senderStatus) { + senderStatus.innerHTML = connected ? + `
+ + WebSocket已连接 +
` : + `
+ + 连接断开 +
`; + } + + if (currentRole === 'receiver' && receiverStatus) { + // 接收方的状态更新由updateP2PStatus处理 + } +} + +// 更新房间状态显示 +function updateRoomStatus(status) { + console.log('更新房间状态:', status); + + const totalClients = status.sender_count + status.receiver_count; + + // 更新发送方界面的房间状态 + if (currentRole === 'sender') { + const onlineCountEl = document.getElementById('onlineCount'); + const senderCountEl = document.getElementById('senderCount'); + const receiverCountEl = document.getElementById('receiverCount'); + + if (onlineCountEl) onlineCountEl.textContent = totalClients; + if (senderCountEl) senderCountEl.textContent = status.sender_count; + if (receiverCountEl) receiverCountEl.textContent = status.receiver_count; + + const clientsList = document.getElementById('clientsList'); + if (clientsList) { + clientsList.innerHTML = ''; + + status.clients.forEach(client => { + if (client.id !== currentClientId) { // 不显示自己 + const clientDiv = document.createElement('div'); + clientDiv.className = 'text-xs text-blue-600'; + const role = client.role === 'sender' ? '📤 发送' : '📥 接收'; + const joinTime = new Date(client.joined_at).toLocaleTimeString(); + clientDiv.textContent = `${role} - ${joinTime}`; + clientsList.appendChild(clientDiv); + } + }); + } + + // 显示房间状态区域 + const roomStatusSection = document.getElementById('roomStatusSection'); + if (roomStatusSection) { + roomStatusSection.classList.remove('hidden'); + } + } + + // 更新接收方界面的房间状态 + if (currentRole === 'receiver') { + const receiverOnlineCountEl = document.getElementById('receiverOnlineCount'); + const receiverSenderCountEl = document.getElementById('receiverSenderCount'); + const receiverReceiverCountEl = document.getElementById('receiverReceiverCount'); + + if (receiverOnlineCountEl) receiverOnlineCountEl.textContent = totalClients; + if (receiverSenderCountEl) receiverSenderCountEl.textContent = status.sender_count; + if (receiverReceiverCountEl) receiverReceiverCountEl.textContent = status.receiver_count; + + const clientsList = document.getElementById('receiverClientsList'); + if (clientsList) { + clientsList.innerHTML = ''; + + status.clients.forEach(client => { + if (client.id !== currentClientId) { // 不显示自己 + const clientDiv = document.createElement('div'); + clientDiv.className = 'text-xs text-blue-600'; + const role = client.role === 'sender' ? '📤 发送' : '📥 接收'; + const joinTime = new Date(client.joined_at).toLocaleTimeString(); + clientDiv.textContent = `${role} - ${joinTime}`; + clientsList.appendChild(clientDiv); + } + }); + } + } +} + +// 显示房间状态区域 +function showRoomStatus() { + if (currentRole === 'sender') { + const roomStatusSection = document.getElementById('roomStatusSection'); + if (roomStatusSection) { + roomStatusSection.classList.remove('hidden'); + } + } +} + +// 处理文件请求(简化版本,通过WebSocket发送文件) +async function handleFileRequest(payload) { + console.log('处理文件请求:', payload); + + const fileId = payload.file_id; + const requesterId = payload.requester; + const requestId = payload.request_id; + + // 找到对应的文件 + const fileIndex = parseInt(fileId.replace('file_', '')); + const file = selectedFiles[fileIndex]; + + if (!file) { + console.error('未找到请求的文件:', fileId); + return; + } + + console.log('开始发送文件:', file.name, '给客户端:', requesterId); + showNotification(`开始发送文件: ${file.name}`, 'info'); + + // 通过WebSocket发送文件(简化实现) + await sendFileViaWebSocket(file, requestId); +} + +// 通过WebSocket发送文件 +async function sendFileViaWebSocket(file, requestId) { + // 发送文件信息 + const fileInfo = { + type: 'file-info', + payload: { + file_id: requestId, + name: file.name, + size: file.size, + mime_type: file.type, + last_modified: file.lastModified + } + }; + + websocket.send(JSON.stringify(fileInfo)); + + // 分块发送文件 + const chunkSize = 65536; // 64KB chunks (提高传输速度) + let offset = 0; + + const sendChunk = () => { + if (offset >= file.size) { + // 发送完成消息 + const completeMsg = { + type: 'file-complete', + payload: { + file_id: requestId + } + }; + websocket.send(JSON.stringify(completeMsg)); + console.log('文件发送完成:', file.name); + showNotification(`文件发送完成: ${file.name}`, 'success'); + return; + } + + const slice = file.slice(offset, offset + chunkSize); + const reader = new FileReader(); + + reader.onload = (e) => { + const chunk = e.target.result; + + // 发送块元数据和数据 + const chunkData = { + type: 'file-chunk', + payload: { + file_id: requestId, + offset: offset, + data: Array.from(new Uint8Array(chunk)), // 转换为数组以便JSON序列化 + is_last: offset + chunk.byteLength >= file.size + } + }; + + websocket.send(JSON.stringify(chunkData)); + + offset += chunk.byteLength; + + // 减少延时提高传输速度 + setTimeout(sendChunk, 10); // 从50ms减少到10ms + }; + + reader.readAsArrayBuffer(slice); + }; + + sendChunk(); +} + +// 初始化文件传输(接收方) +function initFileTransfer(fileInfo) { + console.log('初始化文件传输:', fileInfo); + + const transferKey = fileInfo.file_id; + + if (!fileTransfers.has(transferKey)) { + fileTransfers.set(transferKey, { + fileId: fileInfo.file_id, + chunks: [], + totalSize: fileInfo.size, + receivedSize: 0, + fileName: fileInfo.name, + mimeType: fileInfo.mime_type, + startTime: Date.now() + }); + + console.log('文件传输已初始化:', transferKey); + showTransferProgress(fileInfo.file_id, 'downloading', fileInfo.name); + } +} + +// 接收文件数据块(接收方) +function receiveFileChunk(chunkData) { + const transferKey = chunkData.file_id; + const transfer = fileTransfers.get(transferKey); + + if (!transfer) { + console.error('未找到对应的文件传输:', transferKey); + return; + } + + // 将数组转换回Uint8Array + const chunkArray = new Uint8Array(chunkData.data); + + // 存储数据块 + transfer.chunks.push({ + offset: chunkData.offset, + data: chunkArray + }); + + transfer.receivedSize += chunkArray.length; + + // 更新进度 + const progress = (transfer.receivedSize / transfer.totalSize) * 100; + updateTransferProgress(chunkData.file_id, progress, transfer.receivedSize, transfer.totalSize); + + console.log(`文件块接收进度: ${progress.toFixed(1)}% (${transfer.receivedSize}/${transfer.totalSize})`); + + // 检查是否是最后一块 + if (chunkData.is_last || transfer.receivedSize >= transfer.totalSize) { + console.log('文件接收完成,开始合并数据块'); + assembleAndDownloadFile(transferKey); + } +} + +// 完成文件下载(接收方) +function completeFileDownload(fileId) { + console.log('文件传输完成:', fileId); + // 这个函数可能不需要,因为在receiveFileChunk中已经处理了完成逻辑 +} + +// 组装文件并触发下载 +function assembleAndDownloadFile(transferKey) { + const transfer = fileTransfers.get(transferKey); + if (!transfer) { + console.error('未找到文件传输信息:', transferKey); + return; + } + + // 按偏移量排序数据块 + transfer.chunks.sort((a, b) => a.offset - b.offset); + + // 合并所有数据块 + const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.data.length, 0); + const mergedData = new Uint8Array(totalSize); + let currentOffset = 0; + + transfer.chunks.forEach(chunk => { + mergedData.set(chunk.data, currentOffset); + currentOffset += chunk.data.length; + }); + + // 创建Blob并触发下载 + const blob = new Blob([mergedData], { type: transfer.mimeType }); + + // 创建下载链接 + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = transfer.fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // 清理传输信息 + fileTransfers.delete(transferKey); + + // 显示完成状态 + hideTransferProgress(transfer.fileId); + + // 恢复下载按钮 + const button = document.querySelector(`button[onclick="downloadFile('${transfer.fileId}')"]`); + if (button) { + button.disabled = false; + button.textContent = '📥 下载'; + } + + const transferTime = (Date.now() - transfer.startTime) / 1000; + const speed = (transfer.totalSize / transferTime / 1024 / 1024).toFixed(2); + + console.log(`文件下载完成: ${transfer.fileName}`); + console.log(`传输时间: ${transferTime.toFixed(1)}秒,平均速度: ${speed} MB/s`); + + // 显示成功消息 + showNotification(`文件 "${transfer.fileName}" 下载完成!传输速度: ${speed} MB/s`, 'success'); +} + +// 显示接收方文件列表 +function displayReceiverFiles(files) { + console.log('displayReceiverFiles被调用, WebSocket状态:', websocket ? websocket.readyState : 'null'); + + document.getElementById('codeInputSection').classList.add('hidden'); + document.getElementById('receiverFilesSection').classList.remove('hidden'); + + const filesList = document.getElementById('receiverFilesList'); + filesList.innerHTML = ''; + + files.forEach((file, index) => { + const fileItem = document.createElement('div'); + fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg'; + fileItem.innerHTML = ` +
+ ${getFileIcon(file.type)} +
+
${file.name}
+
${formatFileSize(file.size)}
+
+
+ + `; + filesList.appendChild(fileItem); + }); + + // 只有在WebSocket未连接时才显示连接中状态 + if (!websocket || websocket.readyState !== WebSocket.OPEN) { + console.log('WebSocket未连接,显示连接中状态'); + updateP2PStatus(false); + } else { + console.log('WebSocket已连接,启用下载功能'); + updateP2PStatus(true); + } +} + +// 下载文件(多人房间版本) +function downloadFile(fileId) { + if (!websocket || websocket.readyState !== WebSocket.OPEN) { + alert('WebSocket连接未建立,请重新连接'); + return; + } + + console.log('请求下载文件:', fileId); + + // 找到文件名(从按钮的父元素中获取) + const button = document.querySelector(`button[onclick="downloadFile('${fileId}')"]`); + let fileName = fileId; // 默认使用fileId + if (button) { + const fileNameEl = button.parentElement.querySelector('.font-medium'); + if (fileNameEl) { + fileName = fileNameEl.textContent; + } + } + + // 生成请求ID用于跟踪请求 + const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + + // 通过WebSocket发送文件请求 + const request = { + type: 'file-request', + payload: { + file_id: fileId, + request_id: requestId + } + }; + + websocket.send(JSON.stringify(request)); + // 不在这里显示进度条,等收到file-info消息时再显示 + + // 禁用下载按钮防止重复点击 + if (button) { + button.disabled = true; + button.textContent = '⏳ 请求中...'; + } +} + +// 更新P2P连接状态 +function updateP2PStatus(connected) { + console.log('updateP2PStatus被调用, connected:', connected, 'currentRole:', currentRole); + + const receiverStatus = document.getElementById('receiverStatus'); + const downloadButtons = document.querySelectorAll('button[onclick^="downloadFile"]'); + + console.log('receiverStatus元素:', receiverStatus); + console.log('找到的下载按钮数量:', downloadButtons.length); + + if (currentRole === 'receiver' && receiverStatus) { + if (connected) { + console.log('设置为已连接状态'); + receiverStatus.innerHTML = ` +
+ + 已连接,可以下载文件 +
`; + + // 启用下载按钮 + downloadButtons.forEach(btn => { + console.log('启用下载按钮:', btn); + btn.disabled = false; + btn.classList.remove('opacity-50', 'cursor-not-allowed'); + btn.classList.add('hover:bg-blue-600'); + if (btn.textContent === '⏳ 请求中...') { + btn.textContent = '📥 下载'; + } + }); + } else { + console.log('设置为连接中状态'); + receiverStatus.innerHTML = ` +
+ + 正在建立连接... +
`; + + // 禁用下载按钮 + downloadButtons.forEach(btn => { + btn.disabled = true; + btn.classList.add('opacity-50', 'cursor-not-allowed'); + btn.classList.remove('hover:bg-blue-600'); + }); + } + } else { + console.log('条件不满足: currentRole=' + currentRole + ', receiverStatus存在=' + !!receiverStatus); + } +} + +// 显示传输进度 +function showTransferProgress(fileId, type, fileName = null) { + const progressContainer = document.getElementById('transferProgress'); + const progressList = document.getElementById('progressList'); + + if (!progressContainer || !progressList) return; + + // 如果已经存在相同文件ID的进度条,先删除 + const existingProgress = document.getElementById(`progress-${fileId}`); + if (existingProgress) { + existingProgress.remove(); + } + + progressContainer.classList.remove('hidden'); + + const displayName = fileName || fileId; + const progressItem = document.createElement('div'); + progressItem.id = `progress-${fileId}`; + progressItem.className = 'bg-gray-100 p-3 rounded-lg'; + progressItem.innerHTML = ` +
+ 文件: ${displayName} + ${type === 'uploading' ? '上传中' : '下载中'} +
+
+
+
+
0%
+ `; + + progressList.appendChild(progressItem); +} + +// 更新传输进度 +function updateTransferProgress(fileId, progress, received, total) { + const progressItem = document.getElementById(`progress-${fileId}`); + if (!progressItem) return; + + const progressBar = progressItem.querySelector('.bg-blue-500'); + const progressText = progressItem.querySelector('.text-sm.text-gray-500:last-child'); + + if (progressBar && progressText) { + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress.toFixed(1)}% (${formatFileSize(received)}/${formatFileSize(total)})`; + } +} + +// 隐藏传输进度 +function hideTransferProgress(fileId) { + const progressItem = document.getElementById(`progress-${fileId}`); + if (progressItem) { + progressItem.remove(); + + // 如果没有其他传输,隐藏进度容器 + const progressList = document.getElementById('progressList'); + if (progressList && progressList.children.length === 0) { + document.getElementById('transferProgress').classList.add('hidden'); + } + } +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 创建通知元素 + const notification = document.createElement('div'); + notification.className = `fixed top-4 right-4 p-4 rounded-lg shadow-lg z-50 max-w-sm ${ + type === 'success' ? 'bg-green-500 text-white' : + type === 'error' ? 'bg-red-500 text-white' : + 'bg-blue-500 text-white' + }`; + notification.textContent = message; + + document.body.appendChild(notification); + + // 3秒后自动移除 + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 3000); +} + +// 工具函数 +function getFileIcon(mimeType) { + if (mimeType.startsWith('image/')) return '🖼️'; + if (mimeType.startsWith('video/')) return '🎥'; + if (mimeType.startsWith('audio/')) return '🎵'; + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('zip') || mimeType.includes('rar')) return '📦'; + return '📄'; +} + +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// 页面卸载时清理 +window.addEventListener('beforeunload', () => { + if (websocket) { + websocket.close(); + } + clientConnections.forEach((conn) => { + if (conn.peerConnection) { + conn.peerConnection.close(); + } + }); +}); diff --git a/web/static/js/p2p-transfer.js b/web/static/js/p2p-transfer.js deleted file mode 100644 index 9119132..0000000 --- a/web/static/js/p2p-transfer.js +++ /dev/null @@ -1,256 +0,0 @@ -// P2P文件传输系统 -// 全局变量 -let websocket = null; -let peerConnection = null; -let dataChannel = null; -let selectedFiles = []; -let currentPickupCode = ''; -let currentRole = ''; // 'sender' or 'receiver' -let fileTransfers = new Map(); // 存储文件传输状态 -let isP2PConnected = false; // P2P连接状态 -let isConnecting = false; // 是否正在连接中 -let connectionTimeout = null; // 连接超时定时器 - -// 页面加载完成后初始化 -document.addEventListener('DOMContentLoaded', () => { - initializeEventListeners(); -}); - -// 初始化事件监听器 -function initializeEventListeners() { - // 文件选择事件 - document.getElementById('fileInput').addEventListener('change', handleFileSelect); - - // 取件码输入事件 - document.getElementById('pickupCodeInput').addEventListener('input', (e) => { - e.target.value = e.target.value.toUpperCase(); - if (e.target.value.length === 6) { - // 自动连接 - setTimeout(() => joinRoom(), 100); - } - }); - - // 拖拽上传 - setupDragAndDrop(); -} - -// 设置拖拽上传 -function setupDragAndDrop() { - const dropArea = document.querySelector('.border-dashed'); - dropArea.addEventListener('dragover', (e) => { - e.preventDefault(); - dropArea.classList.add('border-blue-400'); - }); - - dropArea.addEventListener('dragleave', () => { - dropArea.classList.remove('border-blue-400'); - }); - - dropArea.addEventListener('drop', (e) => { - e.preventDefault(); - dropArea.classList.remove('border-blue-400'); - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - selectedFiles = files; - displaySelectedFiles(); - } - }); -} - -// 处理文件选择 -function handleFileSelect(event) { - const files = Array.from(event.target.files); - if (files.length > 0) { - selectedFiles = files; - displaySelectedFiles(); - } -} - -// 显示选中的文件 -function displaySelectedFiles() { - const container = document.getElementById('selectedFiles'); - const filesList = document.getElementById('filesList'); - - if (selectedFiles.length === 0) { - container.classList.add('hidden'); - return; - } - - container.classList.remove('hidden'); - filesList.innerHTML = ''; - - selectedFiles.forEach((file, index) => { - const fileItem = document.createElement('div'); - fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg'; - fileItem.innerHTML = ` -
- ${getFileIcon(file.type)} -
-
${file.name}
-
${formatFileSize(file.size)}
-
-
- - `; - filesList.appendChild(fileItem); - }); -} - -// 移除文件 -function removeFile(index) { - selectedFiles.splice(index, 1); - displaySelectedFiles(); -} - -// 生成取件码 -async function generatePickupCode() { - if (selectedFiles.length === 0) return; - - // 准备文件信息 - const fileInfos = selectedFiles.map((file, index) => ({ - id: 'file_' + index, - name: file.name, - size: file.size, - type: file.type, - lastModified: file.lastModified - })); - - try { - const response = await fetch('/api/create-room', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ files: fileInfos }) - }); - - const data = await response.json(); - if (data.success) { - currentPickupCode = data.code; - currentRole = 'sender'; - showPickupCode(data.code); - connectWebSocket(); - } else { - alert('生成取件码失败: ' + data.message); - } - } catch (error) { - console.error('生成取件码失败:', error); - alert('生成取件码失败,请重试'); - } -} - -// 显示取件码 -function showPickupCode(code) { - document.getElementById('pickupCodeDisplay').textContent = code; - document.getElementById('pickupCodeSection').classList.remove('hidden'); - document.getElementById('generateCodeBtn').classList.add('hidden'); -} - -// 复制取件码 -function copyPickupCode() { - navigator.clipboard.writeText(currentPickupCode).then(() => { - alert('取件码已复制到剪贴板'); - }); -} - -// 重置发送方 -function resetSender() { - selectedFiles = []; - currentPickupCode = ''; - currentRole = ''; - if (websocket) { - websocket.close(); - } - - document.getElementById('selectedFiles').classList.add('hidden'); - document.getElementById('pickupCodeSection').classList.add('hidden'); - document.getElementById('generateCodeBtn').classList.remove('hidden'); - document.getElementById('fileInput').value = ''; -} - -// 加入房间 -async function joinRoom() { - const code = document.getElementById('pickupCodeInput').value.trim(); - if (code.length !== 6) { - alert('请输入6位取件码'); - return; - } - - try { - const response = await fetch(`/api/room-info?code=${code}`); - const data = await response.json(); - - if (data.success) { - currentPickupCode = code; - currentRole = 'receiver'; - displayReceiverFiles(data.files); - connectWebSocket(); - } else { - alert(data.message); - } - } catch (error) { - console.error('连接失败:', error); - alert('连接失败,请检查取件码是否正确'); - } -} - -// 显示接收方文件列表 -function displayReceiverFiles(files) { - document.getElementById('codeInputSection').classList.add('hidden'); - document.getElementById('receiverFilesSection').classList.remove('hidden'); - - const filesList = document.getElementById('receiverFilesList'); - filesList.innerHTML = ''; - - files.forEach((file, index) => { - const fileItem = document.createElement('div'); - fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg'; - fileItem.innerHTML = ` -
- ${getFileIcon(file.type)} -
-
${file.name}
-
${formatFileSize(file.size)}
-
-
- - `; - filesList.appendChild(fileItem); - }); - - // 初始化时显示正在建立连接状态 - updateP2PStatus(false); -} - -// 工具函数 -function getFileIcon(mimeType) { - if (mimeType.startsWith('image/')) return '🖼️'; - if (mimeType.startsWith('video/')) return '🎥'; - if (mimeType.startsWith('audio/')) return '🎵'; - if (mimeType.includes('pdf')) return '📄'; - if (mimeType.includes('zip') || mimeType.includes('rar')) return '📦'; - return '📄'; -} - -function formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -// 页面卸载时清理 -window.addEventListener('beforeunload', () => { - if (websocket) { - websocket.close(); - } - if (peerConnection) { - peerConnection.close(); - } -}); diff --git a/web/static/js/webrtc-connection.js b/web/static/js/webrtc-connection.js index ec76a71..61abe5d 100644 --- a/web/static/js/webrtc-connection.js +++ b/web/static/js/webrtc-connection.js @@ -1,9 +1,18 @@ // WebSocket和WebRTC连接管理 +// 全局变量 +let clientConnections = new Map(); // 存储与其他客户端的P2P连接 +let currentClientId = ''; // 当前客户端ID + // WebSocket连接 function connectWebSocket() { console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode); + if (!currentPickupCode || !currentRole) { + console.error('缺少必要参数:取件码或角色'); + return; + } + if (isConnecting) { console.log('已在连接中,跳过'); return; @@ -22,54 +31,228 @@ function connectWebSocket() { const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${currentPickupCode}&role=${currentRole}`; console.log('WebSocket URL:', wsUrl); - websocket = new WebSocket(wsUrl); - - websocket.onopen = () => { - console.log('WebSocket连接已建立'); - isConnecting = false; - updateConnectionStatus(true); + try { + websocket = new WebSocket(wsUrl); - // 发送方在WebSocket连接建立后立即初始化P2P(但不创建offer) - if (currentRole === 'sender') { - console.log('发送方初始化P2P连接(等待接收方就绪)'); - initPeerConnectionForSender(); - } - }; - - websocket.onmessage = async (event) => { - try { - const message = JSON.parse(event.data); - await handleWebSocketMessage(message); - } catch (error) { - console.error('解析WebSocket消息失败:', error); - } - }; - - websocket.onerror = (error) => { - console.error('WebSocket错误:', error); - isConnecting = false; - updateConnectionStatus(false); - updateP2PStatus(false); - }; - - websocket.onclose = (event) => { - console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason); - isConnecting = false; - updateConnectionStatus(false); - updateP2PStatus(false); - websocket = null; + websocket.onopen = () => { + console.log('WebSocket连接已建立'); + isConnecting = false; + updateConnectionStatus(true); + + // 连接建立后,启用P2P功能 + if (currentRole === 'receiver') { + updateP2PStatus(true); // 接收方连接成功后立即启用下载 + } + + // 发送方在WebSocket连接建立后初始化(等待接收方连接) + if (currentRole === 'sender') { + console.log('发送方初始化完成,等待接收方连接'); + showRoomStatus(); + } + }; - // 如果不是正常关闭且还需要连接,尝试重连 - if (event.code !== 1000 && currentPickupCode && !isConnecting) { - console.log('WebSocket异常关闭,5秒后尝试重连'); - setTimeout(() => { - if (currentPickupCode && !websocket && !isConnecting) { - console.log('尝试重新连接WebSocket'); - connectWebSocket(); + websocket.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + console.log('收到WebSocket消息:', message); + await handleWebSocketMessage(message); + } catch (error) { + console.error('解析WebSocket消息失败:', error, event.data); + } + }; + + websocket.onerror = (error) => { + console.error('WebSocket错误:', error); + isConnecting = false; + updateConnectionStatus(false); + updateP2PStatus(false); + showNotification('WebSocket连接失败,请检查网络连接', 'error'); + }; + + websocket.onclose = (event) => { + console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason); + isConnecting = false; + updateConnectionStatus(false); + updateP2PStatus(false); + websocket = null; + + // 清理所有P2P连接 + clientConnections.forEach((conn, clientId) => { + if (conn.peerConnection) { + conn.peerConnection.close(); } - }, 5000); + }); + clientConnections.clear(); + + // 如果不是正常关闭且还需要连接,尝试重连 + if (event.code !== 1000 && currentPickupCode && !isConnecting) { + console.log('WebSocket异常关闭,5秒后尝试重连'); + showNotification('连接断开,5秒后自动重连...', 'info'); + setTimeout(() => { + if (currentPickupCode && !websocket && !isConnecting) { + console.log('尝试重新连接WebSocket'); + connectWebSocket(); + } + }, 5000); + } + }; + + // 设置连接超时 + setTimeout(() => { + if (websocket && websocket.readyState === WebSocket.CONNECTING) { + console.log('WebSocket连接超时'); + websocket.close(); + showNotification('连接超时,请重试', 'error'); + } + }, 10000); + + } catch (error) { + console.error('创建WebSocket连接失败:', error); + isConnecting = false; + showNotification('无法创建WebSocket连接', 'error'); + } +} + +// 处理WebSocket消息 +async function handleWebSocketMessage(message) { + console.log('处理WebSocket消息:', message.type, message); + + switch (message.type) { + case 'file-list': + // 接收到文件列表 + if (currentRole === 'receiver') { + displayReceiverFiles(message.payload.files); + } + break; + + case 'room-status': + // 房间状态更新 + updateRoomStatus(message.payload); + break; + + case 'new-receiver': + // 新接收方加入 + if (currentRole === 'sender') { + console.log('新接收方加入:', message.payload.client_id); + // 发送方可以准备为新接收方创建P2P连接 + } + break; + + case 'new-sender': + // 新发送方加入 + if (currentRole === 'receiver') { + console.log('新发送方加入:', message.payload.client_id); + } + break; + + case 'client-left': + // 客户端离开 + console.log('客户端离开:', message.payload.client_id, message.payload.role); + // 清理对应的P2P连接 + if (clientConnections.has(message.payload.client_id)) { + const conn = clientConnections.get(message.payload.client_id); + if (conn.peerConnection) { + conn.peerConnection.close(); + } + clientConnections.delete(message.payload.client_id); + } + break; + + case 'file-request': + // 文件请求 + if (currentRole === 'sender') { + await handleFileRequest(message.payload); + } + break; + + // WebRTC信令消息 + case 'offer': + await handleOffer(message.payload); + break; + case 'answer': + await handleAnswer(message.payload); + break; + case 'ice-candidate': + await handleIceCandidate(message.payload); + break; + + default: + console.log('未知消息类型:', message.type); + } +} + +// 更新房间状态显示 +function updateRoomStatus(status) { + console.log('更新房间状态:', status); + + const totalClients = status.sender_count + status.receiver_count; + + // 更新发送方界面的房间状态 + if (currentRole === 'sender') { + const onlineCountEl = document.getElementById('onlineCount'); + const senderCountEl = document.getElementById('senderCount'); + const receiverCountEl = document.getElementById('receiverCount'); + + if (onlineCountEl) onlineCountEl.textContent = totalClients; + if (senderCountEl) senderCountEl.textContent = status.sender_count; + if (receiverCountEl) receiverCountEl.textContent = status.receiver_count; + + const clientsList = document.getElementById('clientsList'); + if (clientsList) { + clientsList.innerHTML = ''; + + status.clients.forEach(client => { + if (client.id !== currentClientId) { // 不显示自己 + const clientDiv = document.createElement('div'); + clientDiv.className = 'text-xs text-blue-600'; + const role = client.role === 'sender' ? '📤 发送' : '📥 接收'; + const joinTime = new Date(client.joined_at).toLocaleTimeString(); + clientDiv.textContent = `${role} - ${joinTime}`; + clientsList.appendChild(clientDiv); + } + }); } - }; + + // 显示房间状态区域 + const roomStatusSection = document.getElementById('roomStatusSection'); + if (roomStatusSection) { + roomStatusSection.classList.remove('hidden'); + } + } + + // 更新接收方界面的房间状态 + if (currentRole === 'receiver') { + const receiverOnlineCountEl = document.getElementById('receiverOnlineCount'); + const receiverSenderCountEl = document.getElementById('receiverSenderCount'); + const receiverReceiverCountEl = document.getElementById('receiverReceiverCount'); + + if (receiverOnlineCountEl) receiverOnlineCountEl.textContent = totalClients; + if (receiverSenderCountEl) receiverSenderCountEl.textContent = status.sender_count; + if (receiverReceiverCountEl) receiverReceiverCountEl.textContent = status.receiver_count; + + const clientsList = document.getElementById('receiverClientsList'); + if (clientsList) { + clientsList.innerHTML = ''; + + status.clients.forEach(client => { + if (client.id !== currentClientId) { // 不显示自己 + const clientDiv = document.createElement('div'); + clientDiv.className = 'text-xs text-blue-600'; + const role = client.role === 'sender' ? '📤 发送' : '📥 接收'; + const joinTime = new Date(client.joined_at).toLocaleTimeString(); + clientDiv.textContent = `${role} - ${joinTime}`; + clientsList.appendChild(clientDiv); + } + }); + } + } +} + +// 显示房间状态区域 +function showRoomStatus() { + if (currentRole === 'sender') { + document.getElementById('roomStatusSection').classList.remove('hidden'); + } } // 更新连接状态 @@ -81,13 +264,358 @@ function updateConnectionStatus(connected) { senderStatus.innerHTML = connected ? `
- 接收方已连接 + WebSocket已连接
` : - `
- - 等待接收方连接... + `
+ + 连接断开
`; } + + if (currentRole === 'receiver' && receiverStatus) { + receiverStatus.innerHTML = connected ? + `
+ + 已连接,可以下载文件 +
` : + `
+ + 连接断开 +
`; + } +} + +// 处理文件请求 +async function handleFileRequest(payload) { + console.log('处理文件请求:', payload); + + const fileId = payload.file_id; + const requesterId = payload.requester; + const requestId = payload.request_id; + + // 找到对应的文件 + const file = selectedFiles.find(f => f.id === fileId || selectedFiles.indexOf(f).toString() === fileId); + if (!file) { + console.error('未找到请求的文件:', fileId); + return; + } + + // 创建或获取与请求者的P2P连接 + let connection = clientConnections.get(requesterId); + if (!connection) { + connection = await createPeerConnection(requesterId); + clientConnections.set(requesterId, connection); + } + + // 发送文件 + if (connection.dataChannel && connection.dataChannel.readyState === 'open') { + await sendFileToClient(file, connection.dataChannel, requestId); + } else { + console.log('等待数据通道建立...'); + connection.pendingFiles = connection.pendingFiles || []; + connection.pendingFiles.push({ file, requestId }); + } +} + +// 创建P2P连接 +async function createPeerConnection(targetClientId) { + console.log('创建P2P连接到:', targetClientId); + + const connection = { + peerConnection: null, + dataChannel: null, + pendingFiles: [] + }; + + const pc = new RTCPeerConnection({ + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + }); + + connection.peerConnection = pc; + + // 创建数据通道(发送方) + if (currentRole === 'sender') { + const dataChannel = pc.createDataChannel('fileTransfer', { + ordered: true + }); + + connection.dataChannel = dataChannel; + + dataChannel.onopen = () => { + console.log('数据通道已打开,可以传输文件'); + // 发送待发送的文件 + if (connection.pendingFiles && connection.pendingFiles.length > 0) { + connection.pendingFiles.forEach(({ file, requestId }) => { + sendFileToClient(file, dataChannel, requestId); + }); + connection.pendingFiles = []; + } + }; + + dataChannel.onmessage = (event) => { + console.log('数据通道收到消息:', event.data); + }; + } + + // 处理数据通道(接收方) + pc.ondatachannel = (event) => { + const channel = event.channel; + connection.dataChannel = channel; + + channel.onopen = () => { + console.log('接收方数据通道已打开'); + }; + + channel.onmessage = (event) => { + handleFileData(event.data, targetClientId); + }; + }; + + // ICE候选者 + pc.onicecandidate = (event) => { + if (event.candidate) { + websocket.send(JSON.stringify({ + type: 'ice-candidate', + payload: { + candidate: event.candidate, + target_client: targetClientId + } + })); + } + }; + + return connection; +} + +// 处理WebRTC信令消息 +async function handleOffer(payload) { + console.log('处理offer:', payload); + // 实现WebRTC offer处理逻辑 +} + +async function handleAnswer(payload) { + console.log('处理answer:', payload); + // 实现WebRTC answer处理逻辑 +} + +// 发送文件给客户端 +async function sendFileToClient(file, dataChannel, requestId) { + console.log('开始发送文件:', file.name, '到客户端'); + + // 发送文件信息 + const fileInfo = { + type: 'file-info', + file_id: requestId, + name: file.name, + size: file.size, + mime_type: file.type, + last_modified: file.lastModified + }; + + dataChannel.send(JSON.stringify(fileInfo)); + + // 分块发送文件 + const chunkSize = 65536; // 64KB chunks + let offset = 0; + + const sendChunk = () => { + if (offset >= file.size) { + // 发送完成消息 + const completeMsg = { + type: 'file-complete', + file_id: requestId + }; + dataChannel.send(JSON.stringify(completeMsg)); + console.log('文件发送完成:', file.name); + return; + } + + const slice = file.slice(offset, offset + chunkSize); + const reader = new FileReader(); + + reader.onload = (e) => { + const chunk = e.target.result; + + // 发送块元数据 + const metadata = { + type: 'file-chunk-meta', + file_id: requestId, + offset: offset, + size: chunk.byteLength, + is_last: offset + chunk.byteLength >= file.size + }; + + dataChannel.send(JSON.stringify(metadata)); + + // 发送二进制数据 + dataChannel.send(chunk); + + offset += chunk.byteLength; + + // 继续发送下一块 + setTimeout(sendChunk, 10); // 小延时以避免阻塞 + }; + + reader.readAsArrayBuffer(slice); + }; + + sendChunk(); +} + +// 处理接收到的文件数据 +function handleFileData(data, senderId) { + console.log('从发送方接收文件数据:', senderId); + + // 检查是否是二进制数据 + if (data instanceof ArrayBuffer) { + // 处理二进制数据块 + if (pendingChunkMeta) { + receiveFileChunk(pendingChunkMeta, data, senderId); + pendingChunkMeta = null; + } + } else { + // 处理JSON消息 + try { + const message = JSON.parse(data); + console.log('接收到文件传输消息:', message.type); + + switch (message.type) { + case 'file-chunk-meta': + // 存储chunk元数据,等待二进制数据 + pendingChunkMeta = message; + break; + + case 'file-info': + // 初始化文件传输 + initFileTransfer(message, senderId); + break; + + case 'file-complete': + // 文件传输完成 + completeFileDownload(message.file_id, senderId); + break; + + default: + console.log('未知文件传输消息类型:', message.type); + } + } catch (error) { + console.error('解析文件传输消息失败:', error); + } + } +} + +// 初始化文件传输 +function initFileTransfer(fileInfo, senderId) { + console.log('初始化文件传输:', fileInfo); + + const transferKey = `${fileInfo.file_id}_${senderId}`; + + if (!fileTransfers.has(transferKey)) { + fileTransfers.set(transferKey, { + fileId: fileInfo.file_id, + senderId: senderId, + chunks: [], + totalSize: fileInfo.size, + receivedSize: 0, + fileName: fileInfo.name, + mimeType: fileInfo.mime_type || fileInfo.type, + startTime: Date.now() + }); + + console.log('文件传输已初始化:', transferKey); + } +} + +// 接收文件数据块 +function receiveFileChunk(metadata, chunk, senderId) { + const transferKey = `${metadata.file_id}_${senderId}`; + const transfer = fileTransfers.get(transferKey); + + if (!transfer) { + console.error('未找到对应的文件传输:', transferKey); + return; + } + + // 存储数据块 + transfer.chunks.push({ + offset: metadata.offset, + data: chunk + }); + + transfer.receivedSize += chunk.byteLength; + + // 更新进度 + const progress = (transfer.receivedSize / transfer.totalSize) * 100; + updateTransferProgress(metadata.file_id, progress, transfer.receivedSize, transfer.totalSize); + + console.log(`文件块接收进度: ${progress.toFixed(1)}% (${transfer.receivedSize}/${transfer.totalSize})`); + + // 检查是否是最后一块 + if (metadata.is_last || transfer.receivedSize >= transfer.totalSize) { + console.log('文件接收完成,开始合并数据块'); + assembleAndDownloadFile(transferKey); + } +} + +// 组装文件并触发下载 +function assembleAndDownloadFile(transferKey) { + const transfer = fileTransfers.get(transferKey); + if (!transfer) { + console.error('未找到文件传输信息:', transferKey); + return; + } + + // 按偏移量排序数据块 + transfer.chunks.sort((a, b) => a.offset - b.offset); + + // 合并所有数据块 + const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0); + const mergedData = new Uint8Array(totalSize); + let currentOffset = 0; + + transfer.chunks.forEach(chunk => { + const chunkView = new Uint8Array(chunk.data); + mergedData.set(chunkView, currentOffset); + currentOffset += chunkView.length; + }); + + // 创建Blob并触发下载 + const blob = new Blob([mergedData], { type: transfer.mimeType }); + + // 创建下载链接 + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = transfer.fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // 清理传输信息 + fileTransfers.delete(transferKey); + + // 显示完成状态 + hideTransferProgress(transfer.fileId); + + // 恢复下载按钮 + const button = document.querySelector(`button[onclick="downloadFile('${transfer.fileId}')"]`); + if (button) { + button.disabled = false; + button.textContent = '📥 下载'; + } + + const transferTime = (Date.now() - transfer.startTime) / 1000; + const speed = (transfer.totalSize / transferTime / 1024 / 1024).toFixed(2); + + console.log(`文件下载完成: ${transfer.fileName}`); + console.log(`传输时间: ${transferTime.toFixed(1)}秒,平均速度: ${speed} MB/s`); + + // 显示成功消息 + showNotification(`文件 "${transfer.fileName}" 下载完成!传输速度: ${speed} MB/s`, 'success'); } // 为发送方初始化P2P连接(不立即创建offer) diff --git a/web/static/test-websocket.html b/web/static/test-websocket.html new file mode 100644 index 0000000..5451b45 --- /dev/null +++ b/web/static/test-websocket.html @@ -0,0 +1,50 @@ + + + + + + WebSocket连接测试 + + +

WebSocket连接测试

+
未连接
+ +
+ + + + diff --git a/web/static/websocket-debug.html b/web/static/websocket-debug.html new file mode 100644 index 0000000..d965c36 --- /dev/null +++ b/web/static/websocket-debug.html @@ -0,0 +1,127 @@ + + + + WebSocket调试测试 + + +

WebSocket连接测试

+
+ + + + + +
+ +
+

连接状态:未连接

+

日志:

+
+
+ + + + diff --git a/web/templates/index.html b/web/templates/index.html index b7746df..87f9172 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -58,6 +58,17 @@ 等待接收方连接...
+ + + @@ -93,6 +104,17 @@ 已连接,可以下载文件 + + +
+
房间状态
+
+
在线用户: 0
+
发送方: 0
+
接收方: 0
+
+
+
@@ -109,7 +131,5 @@ {{define "scripts"}} - - - + {{end}}