mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-15 09:44:45 +08:00
支持多人加入/下载
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,7 +4,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
.github/*
|
||||
# 测试二进制文件
|
||||
*.test
|
||||
|
||||
|
||||
355
README.md
Normal file
355
README.md
Normal file
@@ -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 <repository-url>
|
||||
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]
|
||||
|
||||
---
|
||||
|
||||
⭐ 如果这个项目对你有帮助,请给它一个星标!
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
1005
web/static/js/p2p-transfer-new.js
Normal file
1005
web/static/js/p2p-transfer-new.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="removeFile(${index})" class="text-red-500 hover:text-red-700 p-1">
|
||||
❌
|
||||
</button>
|
||||
`;
|
||||
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 = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="downloadFile('${file.id}')" disabled
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded font-semibold opacity-50 cursor-not-allowed">
|
||||
📥 下载
|
||||
</button>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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 ?
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
接收方已连接
|
||||
WebSocket已连接
|
||||
</div>` :
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
||||
等待接收方连接...
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-red-100 text-red-800">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
连接断开
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (currentRole === 'receiver' && receiverStatus) {
|
||||
receiverStatus.innerHTML = connected ?
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
已连接,可以下载文件
|
||||
</div>` :
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-red-100 text-red-800">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
连接断开
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件请求
|
||||
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)
|
||||
|
||||
50
web/static/test-websocket.html
Normal file
50
web/static/test-websocket.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket连接测试</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket连接测试</h1>
|
||||
<div id="status">未连接</div>
|
||||
<button onclick="testConnection()">测试连接</button>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
function log(message) {
|
||||
const logDiv = document.getElementById('log');
|
||||
logDiv.innerHTML += '<div>' + new Date().toLocaleTimeString() + ': ' + message + '</div>';
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
const code = '354888'; // 使用已存在的房间码
|
||||
const role = 'receiver';
|
||||
const wsUrl = `ws://localhost:8080/ws/p2p?code=${code}&role=${role}`;
|
||||
|
||||
log('尝试连接: ' + wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function() {
|
||||
log('WebSocket连接成功!');
|
||||
document.getElementById('status').textContent = '已连接';
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
log('WebSocket错误: ' + JSON.stringify(error));
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
log('WebSocket关闭: 代码=' + event.code + ', 原因=' + event.reason);
|
||||
document.getElementById('status').textContent = '已断开';
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
log('收到消息: ' + event.data);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
127
web/static/websocket-debug.html
Normal file
127
web/static/websocket-debug.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebSocket调试测试</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket连接测试</h1>
|
||||
<div>
|
||||
<input type="text" id="testCode" placeholder="输入取件码" maxlength="6" style="padding: 10px; margin: 10px;">
|
||||
<select id="testRole" style="padding: 10px; margin: 10px;">
|
||||
<option value="sender">发送方</option>
|
||||
<option value="receiver">接收方</option>
|
||||
</select>
|
||||
<button onclick="testWebSocket()" style="padding: 10px; margin: 10px;">测试连接</button>
|
||||
<button onclick="closeWebSocket()" style="padding: 10px; margin: 10px;">关闭连接</button>
|
||||
<button onclick="clearLog()" style="padding: 10px; margin: 10px;">清空日志</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>连接状态:<span id="status">未连接</span></h2>
|
||||
<h3>日志:</h3>
|
||||
<div id="log" style="border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: auto; font-family: monospace; background: #f5f5f5;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let testSocket = null;
|
||||
let logElement = document.getElementById('log');
|
||||
let statusElement = document.getElementById('status');
|
||||
|
||||
function log(message) {
|
||||
const time = new Date().toLocaleTimeString();
|
||||
logElement.innerHTML += `[${time}] ${message}<br>`;
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
console.log(`[${time}] ${message}`);
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
statusElement.textContent = status;
|
||||
log(`状态更新: ${status}`);
|
||||
}
|
||||
|
||||
function testWebSocket() {
|
||||
const code = document.getElementById('testCode').value.trim();
|
||||
const role = document.getElementById('testRole').value;
|
||||
|
||||
if (!code) {
|
||||
log('错误: 请输入取件码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (testSocket) {
|
||||
log('关闭现有连接...');
|
||||
testSocket.close();
|
||||
testSocket = null;
|
||||
}
|
||||
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${code}&role=${role}`;
|
||||
|
||||
log(`尝试连接: ${wsUrl}`);
|
||||
updateStatus('连接中...');
|
||||
|
||||
try {
|
||||
testSocket = new WebSocket(wsUrl);
|
||||
|
||||
testSocket.onopen = function(event) {
|
||||
log('✅ WebSocket连接成功建立');
|
||||
updateStatus('已连接');
|
||||
};
|
||||
|
||||
testSocket.onmessage = function(event) {
|
||||
let message = event.data;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
log(`📥 收到消息 (${message.type}): ${JSON.stringify(message, null, 2)}`);
|
||||
} catch (e) {
|
||||
log(`📥 收到原始消息: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
testSocket.onerror = function(error) {
|
||||
log(`❌ WebSocket错误: ${error}`);
|
||||
updateStatus('连接错误');
|
||||
};
|
||||
|
||||
testSocket.onclose = function(event) {
|
||||
log(`🔌 连接关闭 - 代码: ${event.code}, 原因: ${event.reason}, 是否干净关闭: ${event.wasClean}`);
|
||||
updateStatus('连接已关闭');
|
||||
testSocket = null;
|
||||
};
|
||||
|
||||
// 连接超时检测
|
||||
setTimeout(() => {
|
||||
if (testSocket && testSocket.readyState === WebSocket.CONNECTING) {
|
||||
log('⏰ 连接超时 (10秒)');
|
||||
testSocket.close();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 创建WebSocket失败: ${error.message}`);
|
||||
updateStatus('创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
function closeWebSocket() {
|
||||
if (testSocket) {
|
||||
log('手动关闭连接...');
|
||||
testSocket.close();
|
||||
testSocket = null;
|
||||
} else {
|
||||
log('没有活动连接');
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// 页面加载时显示信息
|
||||
window.onload = function() {
|
||||
log('WebSocket测试页面已加载');
|
||||
log('请先创建一个房间(使用取件码),然后测试连接');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,6 +58,17 @@
|
||||
等待接收方连接...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 房间状态显示 -->
|
||||
<div id="roomStatusSection" class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg hidden">
|
||||
<h5 class="font-semibold text-blue-800 mb-2">房间状态</h5>
|
||||
<div id="roomConnections" class="text-sm text-blue-700">
|
||||
<div>在线用户: <span id="onlineCount">0</span> 人</div>
|
||||
<div>发送方: <span id="senderCount">0</span> 人</div>
|
||||
<div>接收方: <span id="receiverCount">0</span> 人</div>
|
||||
</div>
|
||||
<div id="clientsList" class="mt-2 space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +104,17 @@
|
||||
已连接,可以下载文件
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 房间状态显示 (接收方) -->
|
||||
<div id="receiverRoomStatusSection" class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h5 class="font-semibold text-blue-800 mb-2">房间状态</h5>
|
||||
<div id="receiverRoomConnections" class="text-sm text-blue-700">
|
||||
<div>在线用户: <span id="receiverOnlineCount">0</span> 人</div>
|
||||
<div>发送方: <span id="receiverSenderCount">0</span> 人</div>
|
||||
<div>接收方: <span id="receiverReceiverCount">0</span> 人</div>
|
||||
</div>
|
||||
<div id="receiverClientsList" class="mt-2 space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +131,5 @@
|
||||
|
||||
{{define "scripts"}}
|
||||
<!-- P2P文件传输相关脚本 -->
|
||||
<script src="/static/js/p2p-transfer.js"></script>
|
||||
<script src="/static/js/webrtc-connection.js"></script>
|
||||
<script src="/static/js/file-transfer.js"></script>
|
||||
<script src="/static/js/p2p-transfer-new.js"></script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user