commit 70ad644a71785d9a2ffaeaf2bf5d19bb11b729c0 Author: MatrixSeven Date: Mon Jul 28 16:33:10 2025 +0800 第一版本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58945fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Go相关 +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# 测试二进制文件 +*.test + +# 输出文件 +*.out + +# Go工作区文件 +go.work + +# 构建目录 +build/ +dist/ + +# 上传文件目录 +uploads/* +!uploads/.gitkeep + +# 日志文件 +logs/ +*.log + +# 临时文件 +tmp/ +temp/ + +# IDE相关 +.vscode/ +.idea/ +*.swp +*.swo + +# 系统文件 +.DS_Store +Thumbs.db + +# 环境变量文件 +.env +.env.local +.env.production + +# 依赖目录 +vendor/ + +# 覆盖率报告 +coverage.out +coverage.html + +# Air热重载工具配置 +.air.toml + +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# Redis数据 +dump.rdb + +# 缓存文件 +*.cache + +# 证书文件 +*.pem +*.key +*.crt + +# 配置文件(可能包含敏感信息) +config.json +config.yaml +config.yml + +# Docker相关 +docker-compose.override.yml + +# 编辑器备份文件 +*~ +*.bak +*.backup + +# 压缩文件 +*.zip +*.tar.gz +*.tgz + +# Node.js相关(如果有前端构建) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 生产环境文件 +production/ + +# 备份文件 +backup/ + +*.log +/bin/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..119bdd8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# 多阶段构建 - 构建阶段 +FROM golang:1.21-alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 安装必要的工具 +RUN apk add --no-cache git ca-certificates tzdata + +# 复制go.mod和go.sum文件 +COPY go.mod go.sum ./ + +# 下载依赖 +RUN go mod download + +# 复制源代码 +COPY . . + +# 构建应用程序 +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main cmd/main.go + +# 运行阶段 +FROM alpine:latest + +# 安装ca-certificates用于HTTPS请求 +RUN apk --no-cache add ca-certificates tzdata + +# 设置时区 +ENV TZ=Asia/Shanghai + +WORKDIR /root/ + +# 从构建阶段复制二进制文件 +COPY --from=builder /app/main . + +# 复制静态文件 +COPY --from=builder /app/web ./web + +# 创建必要的目录 +RUN mkdir -p uploads logs + +# 设置权限 +RUN chmod +x ./main + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 + +# 运行应用程序 +CMD ["./main"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..818e529 --- /dev/null +++ b/Makefile @@ -0,0 +1,185 @@ +# Makefile for Chuan File Transfer System + +# 默认目标 +.PHONY: help +help: + @echo "可用的命令:" + @echo " run - 运行应用程序" + @echo " build - 构建应用程序" + @echo " clean - 清理构建文件" + @echo " deps - 安装依赖" + @echo " test - 运行测试" + @echo " docker - 构建Docker镜像" + +# 应用程序名称 +APP_NAME=chuan +BUILD_DIR=build +MAIN_FILE=cmd/main.go + +# Go相关命令 +GO=go +GOCMD=$(GO) +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOMOD=$(GOCMD) mod + +# 构建标志 +LDFLAGS=-ldflags "-X main.Version=1.0.0 -X main.BuildTime=$$(date +'%Y-%m-%d %H:%M:%S')" + +# 运行应用程序 +.PHONY: run +run: + @echo "启动文件传输系统..." + @mkdir -p uploads + $(GOCMD) run $(MAIN_FILE) + +# 构建应用程序 +.PHONY: build +build: + @echo "构建应用程序..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_FILE) + +# 构建Linux版本 +.PHONY: build-linux +build-linux: + @echo "构建Linux版本..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-linux $(MAIN_FILE) + +# 构建Windows版本 +.PHONY: build-windows +build-windows: + @echo "构建Windows版本..." + @mkdir -p $(BUILD_DIR) + GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-windows.exe $(MAIN_FILE) + +# 构建MacOS版本 +.PHONY: build-macos +build-macos: + @echo "构建MacOS版本..." + @mkdir -p $(BUILD_DIR) + GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME)-macos $(MAIN_FILE) + +# 构建所有平台版本 +.PHONY: build-all +build-all: build-linux build-windows build-macos + @echo "所有平台构建完成" + +# 安装依赖 +.PHONY: deps +deps: + @echo "安装Go模块依赖..." + $(GOMOD) download + $(GOMOD) tidy + +# 运行测试 +.PHONY: test +test: + @echo "运行测试..." + $(GOTEST) -v ./... + +# 清理构建文件 +.PHONY: clean +clean: + @echo "清理构建文件..." + $(GOCLEAN) + rm -rf $(BUILD_DIR) + rm -rf uploads/* + +# 格式化代码 +.PHONY: fmt +fmt: + @echo "格式化Go代码..." + $(GOCMD) fmt ./... + +# 代码检查 +.PHONY: vet +vet: + @echo "运行go vet..." + $(GOCMD) vet ./... + +# 安全检查 +.PHONY: security +security: + @echo "运行安全检查..." + @which gosec > /dev/null || $(GOGET) github.com/securecodewarrior/gosec/v2/cmd/gosec@latest + gosec ./... + +# 性能测试 +.PHONY: bench +bench: + @echo "运行性能测试..." + $(GOTEST) -bench=. -benchmem ./... + +# 代码覆盖率 +.PHONY: coverage +coverage: + @echo "生成代码覆盖率报告..." + $(GOTEST) -coverprofile=coverage.out ./... + $(GOCMD) tool cover -html=coverage.out -o coverage.html + @echo "覆盖率报告已生成: coverage.html" + +# 创建Docker镜像 +.PHONY: docker +docker: + @echo "构建Docker镜像..." + docker build -t $(APP_NAME):latest . + +# 运行Docker容器 +.PHONY: docker-run +docker-run: + @echo "运行Docker容器..." + docker run -p 8080:8080 -v $(PWD)/uploads:/app/uploads $(APP_NAME):latest + +# 开发模式(热重载) +.PHONY: dev +dev: + @echo "启动开发模式(需要安装air)..." + @which air > /dev/null || $(GOGET) github.com/cosmtrek/air@latest + air + +# 安装开发工具 +.PHONY: tools +tools: + @echo "安装开发工具..." + $(GOGET) github.com/cosmtrek/air@latest + $(GOGET) github.com/securecodewarrior/gosec/v2/cmd/gosec@latest + $(GOGET) golang.org/x/tools/cmd/goimports@latest + +# 初始化项目 +.PHONY: init +init: deps tools + @echo "项目初始化完成" + @mkdir -p uploads + @mkdir -p logs + @echo "创建必要的目录" + +# 部署到生产环境 +.PHONY: deploy +deploy: build-linux + @echo "部署到生产环境..." + @echo "请手动将 $(BUILD_DIR)/$(APP_NAME)-linux 上传到服务器" + +# 查看项目状态 +.PHONY: status +status: + @echo "项目状态:" + @echo " Go版本: $$($(GOCMD) version)" + @echo " 项目路径: $$(pwd)" + @echo " 模块信息:" + @$(GOMOD) list -m all | head -10 + +# 生成API文档 +.PHONY: docs +docs: + @echo "生成API文档..." + @which swag > /dev/null || $(GOGET) github.com/swaggo/swag/cmd/swag@latest + swag init -g $(MAIN_FILE) + +# 运行所有检查 +.PHONY: check +check: fmt vet test + @echo "所有检查完成" diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..42de9c1 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "chuan/internal/handlers" + "chuan/internal/services" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func main() { + // 初始化服务 + p2pService := services.NewP2PService() + + // 初始化处理器 + h := handlers.NewHandler(p2pService) + + // 创建路由 + r := chi.NewRouter() + + // 中间件 + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Compress(5)) + + // CORS 配置 + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // 静态文件服务 + fileServer := http.FileServer(http.Dir("./web/static/")) + r.Handle("/static/*", http.StripPrefix("/static", fileServer)) + + // 路由定义 + r.Get("/", h.IndexHandler) + r.Get("/ws/p2p", h.HandleP2PWebSocket) + + // API路由 + r.Post("/api/create-room", h.CreateRoomHandler) + r.Get("/api/room-info", h.GetRoomInfoHandler) + + // 启动服务器 + srv := &http.Server{ + Addr: ":8080", + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // 优雅关闭 + go func() { + log.Printf("服务器启动在端口 :8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务器启动失败: %v", err) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("正在关闭服务器...") + + // 设置关闭超时 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("服务器强制关闭:", err) + } + + log.Println("服务器已退出") +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..39ce19a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,256 @@ +#!/bin/bash + +# 文件传输系统部署脚本 +# 使用方法: ./deploy.sh [环境] +# 环境选项: dev, staging, production + +set -e + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 环境变量 +ENV=${1:-dev} +APP_NAME="chuan" +DOCKER_IMAGE="${APP_NAME}:${ENV}" +COMPOSE_FILE="docker-compose.yml" + +# 检查Docker和Docker Compose +check_dependencies() { + log_info "检查依赖..." + + if ! command -v docker &> /dev/null; then + log_error "Docker未安装,请先安装Docker" + exit 1 + fi + + if ! command -v docker-compose &> /dev/null; then + log_error "Docker Compose未安装,请先安装Docker Compose" + exit 1 + fi + + log_info "依赖检查完成" +} + +# 构建应用 +build_app() { + log_info "构建应用..." + + # 清理旧的构建 + docker-compose down --remove-orphans + docker system prune -f + + # 构建新镜像 + docker-compose build --no-cache + + log_info "应用构建完成" +} + +# 生成SSL证书(开发环境) +generate_ssl_cert() { + if [ "$ENV" = "dev" ]; then + log_info "生成开发环境SSL证书..." + + mkdir -p ssl + + if [ ! -f ssl/cert.pem ] || [ ! -f ssl/key.pem ]; then + openssl req -x509 -newkey rsa:4096 -keyout ssl/key.pem -out ssl/cert.pem -days 365 -nodes \ + -subj "/C=CN/ST=Beijing/L=Beijing/O=Chuan/OU=Dev/CN=localhost" + log_info "SSL证书生成完成" + else + log_info "SSL证书已存在,跳过生成" + fi + fi +} + +# 部署应用 +deploy_app() { + log_info "部署应用到${ENV}环境..." + + # 根据环境选择不同的配置 + case $ENV in + "dev") + export COMPOSE_FILE="docker-compose.yml" + ;; + "staging") + export COMPOSE_FILE="docker-compose.staging.yml" + ;; + "production") + export COMPOSE_FILE="docker-compose.prod.yml" + ;; + *) + log_error "未知环境: $ENV" + exit 1 + ;; + esac + + # 启动服务 + docker-compose up -d + + # 等待服务启动 + log_info "等待服务启动..." + sleep 10 + + # 健康检查 + if curl -f http://localhost:8080/health > /dev/null 2>&1; then + log_info "应用健康检查通过" + else + log_warn "应用健康检查失败,请检查日志" + fi + + log_info "部署完成" +} + +# 显示服务状态 +show_status() { + log_info "服务状态:" + docker-compose ps + + log_info "服务日志(最近20行):" + docker-compose logs --tail=20 +} + +# 备份数据 +backup_data() { + log_info "备份数据..." + + BACKUP_DIR="backup/$(date +%Y%m%d_%H%M%S)" + mkdir -p "$BACKUP_DIR" + + # 备份上传文件 + if [ -d "uploads" ]; then + cp -r uploads "$BACKUP_DIR/" + log_info "上传文件已备份到 $BACKUP_DIR/uploads" + fi + + # 备份Redis数据 + docker-compose exec -T redis redis-cli BGSAVE + docker cp $(docker-compose ps -q redis):/data/dump.rdb "$BACKUP_DIR/" + log_info "Redis数据已备份到 $BACKUP_DIR/dump.rdb" + + log_info "数据备份完成: $BACKUP_DIR" +} + +# 恢复数据 +restore_data() { + BACKUP_DIR=$2 + + if [ -z "$BACKUP_DIR" ]; then + log_error "请指定备份目录" + exit 1 + fi + + if [ ! -d "$BACKUP_DIR" ]; then + log_error "备份目录不存在: $BACKUP_DIR" + exit 1 + fi + + log_info "从 $BACKUP_DIR 恢复数据..." + + # 恢复上传文件 + if [ -d "$BACKUP_DIR/uploads" ]; then + rm -rf uploads/* + cp -r "$BACKUP_DIR/uploads/"* uploads/ + log_info "上传文件已恢复" + fi + + # 恢复Redis数据 + if [ -f "$BACKUP_DIR/dump.rdb" ]; then + docker-compose stop redis + docker cp "$BACKUP_DIR/dump.rdb" $(docker-compose ps -q redis):/data/ + docker-compose start redis + log_info "Redis数据已恢复" + fi + + log_info "数据恢复完成" +} + +# 清理资源 +cleanup() { + log_info "清理资源..." + + docker-compose down --volumes --remove-orphans + docker system prune -af + docker volume prune -f + + log_info "清理完成" +} + +# 显示帮助信息 +show_help() { + echo "文件传输系统部署脚本" + echo "" + echo "使用方法:" + echo " $0 [命令] [环境/参数]" + echo "" + echo "命令:" + echo " deploy [env] - 部署应用 (环境: dev, staging, production)" + echo " build - 构建应用" + echo " status - 显示服务状态" + echo " backup - 备份数据" + echo " restore [dir] - 恢复数据" + echo " cleanup - 清理资源" + echo " help - 显示帮助信息" + echo "" + echo "示例:" + echo " $0 deploy dev # 部署到开发环境" + echo " $0 deploy production # 部署到生产环境" + echo " $0 backup # 备份数据" + echo " $0 restore backup/20241128_120000 # 恢复数据" +} + +# 主函数 +main() { + case ${1:-deploy} in + "deploy") + check_dependencies + generate_ssl_cert + build_app + deploy_app + show_status + ;; + "build") + check_dependencies + build_app + ;; + "status") + show_status + ;; + "backup") + backup_data + ;; + "restore") + restore_data $@ + ;; + "cleanup") + cleanup + ;; + "help"|"-h"|"--help") + show_help + ;; + *) + log_error "未知命令: $1" + show_help + exit 1 + ;; + esac +} + +# 执行主函数 +main $@ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f0d688 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + # 主应用服务 + chuan: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./uploads:/root/uploads + - ./logs:/root/logs + environment: + - TZ=Asia/Shanghai + - PORT=8080 + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + restart: unless-stopped + networks: + - chuan-network + + # Redis服务(用于存储取件码) + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes --requirepass "" + restart: unless-stopped + networks: + - chuan-network + + # Nginx反向代理 + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + - ./uploads:/var/www/uploads + depends_on: + - chuan + restart: unless-stopped + networks: + - chuan-network + +volumes: + redis_data: + +networks: + chuan-network: + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e9ecf0 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module chuan + +go 1.21 + +require ( + github.com/go-chi/chi/v5 v5.0.10 + github.com/go-chi/cors v1.2.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..635b2d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..706bd2f --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "encoding/json" + "html/template" + "net/http" + "path/filepath" + + "chuan/internal/models" + "chuan/internal/services" +) + +type Handler struct { + p2pService *services.P2PService + templates map[string]*template.Template +} + +func NewHandler(p2pService *services.P2PService) *Handler { + h := &Handler{ + p2pService: p2pService, + templates: make(map[string]*template.Template), + } + + // 加载模板 + h.loadTemplates() + return h +} + +// 加载模板 +func (h *Handler) loadTemplates() { + templateDir := "web/templates" + + // 加载基础模板 + baseTemplate := filepath.Join(templateDir, "base.html") + + // 加载各个页面模板 + templates := []string{"index.html"} + + for _, tmplName := range templates { + tmplPath := filepath.Join(templateDir, tmplName) + tmpl, err := template.ParseFiles(baseTemplate, tmplPath) + if err != nil { + panic("加载模板失败: " + err.Error()) + } + h.templates[tmplName] = tmpl + println("模板加载成功:", tmplName) + } +} + +// IndexHandler 首页处理器 +func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) { + tmpl, exists := h.templates["index.html"] + if !exists { + http.Error(w, "模板不存在", http.StatusInternalServerError) + return + } + + data := map[string]interface{}{ + "Title": "P2P文件传输", + } + + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, "渲染模板失败: "+err.Error(), http.StatusInternalServerError) + return + } +} + +// CreateRoomHandler 创建房间API +func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + var req struct { + Files []models.FileTransferInfo `json:"files"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "解析请求失败", http.StatusBadRequest) + return + } + + // 创建房间 + code := h.p2pService.CreateRoom(req.Files) + + response := map[string]interface{}{ + "success": true, + "code": code, + "message": "房间创建成功", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// GetRoomInfoHandler 获取房间信息API +func (h *Handler) GetRoomInfoHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "缺少取件码", http.StatusBadRequest) + return + } + + room, exists := h.p2pService.GetRoomByCode(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, + "files": room.Files, + "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 new file mode 100644 index 0000000..98e55d1 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,68 @@ +package models + +import ( + "time" +) + +// FileInfo 文件信息结构 +type FileInfo struct { + ID string `json:"id"` + FileName string `json:"filename"` + FileSize int64 `json:"file_size"` + ContentType string `json:"content_type"` + Code string `json:"code"` + UploadTime time.Time `json:"upload_time"` + ExpiryTime time.Time `json:"expiry_time"` + DownloadURL string `json:"download_url"` + FilePath string `json:"file_path"` +} + +// UploadResponse 上传响应结构 +type UploadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Code string `json:"code,omitempty"` + FileInfo FileInfo `json:"file_info,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// WebRTCOffer WebRTC offer 结构 +type WebRTCOffer struct { + SDP string `json:"sdp"` + Type string `json:"type"` +} + +// WebRTCAnswer WebRTC answer 结构 +type WebRTCAnswer struct { + SDP string `json:"sdp"` + Type string `json:"type"` +} + +// WebRTCICECandidate ICE candidate 结构 +type WebRTCICECandidate struct { + Candidate string `json:"candidate"` + SDPMLineIndex int `json:"sdpMLineIndex"` + SDPMid string `json:"sdpMid"` +} + +// VideoMessage 视频消息结构 +type VideoMessage struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// FileTransferInfo P2P文件传输信息 +type FileTransferInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + Type string `json:"type"` + LastModified int64 `json:"lastModified"` +} + +// ErrorResponse 错误响应结构 +type ErrorResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Code string `json:"code,omitempty"` +} diff --git a/internal/services/file_service.go b/internal/services/file_service.go new file mode 100644 index 0000000..94db41a --- /dev/null +++ b/internal/services/file_service.go @@ -0,0 +1,168 @@ +package services + +import ( + "crypto/rand" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "chuan/internal/models" + + "github.com/google/uuid" +) + +type FileService struct { + uploadDir string +} + +func NewFileService() *FileService { + return &FileService{ + uploadDir: "./uploads", + } +} + +// SaveFile 保存上传的文件 +func (fs *FileService) SaveFile(file multipart.File, header *multipart.FileHeader) (*models.FileInfo, error) { + // 生成唯一文件ID + fileID := uuid.New().String() + + // 生成取件码 + code := fs.generateCode() + + // 创建文件路径 + fileExt := filepath.Ext(header.Filename) + fileName := fmt.Sprintf("%s%s", fileID, fileExt) + filePath := filepath.Join(fs.uploadDir, fileName) + + // 确保上传目录存在 + if err := os.MkdirAll(fs.uploadDir, 0755); err != nil { + return nil, fmt.Errorf("创建上传目录失败: %v", err) + } + + // 创建目标文件 + dst, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %v", err) + } + defer dst.Close() + + // 复制文件内容 + size, err := io.Copy(dst, file) + if err != nil { + return nil, fmt.Errorf("保存文件失败: %v", err) + } + + // 获取文件内容类型 + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = fs.getContentType(header.Filename) + } + + fileInfo := &models.FileInfo{ + ID: fileID, + FileName: header.Filename, + FileSize: size, + ContentType: contentType, + Code: code, + UploadTime: time.Now(), + ExpiryTime: time.Now().Add(24 * time.Hour), // 24小时过期 + FilePath: filePath, + DownloadURL: fmt.Sprintf("/download/%s", code), + } + + // 存储文件信息到内存(生产环境应使用Redis) + store := GetStore() + if err := store.StoreFileInfo(fileInfo); err != nil { + return nil, fmt.Errorf("存储文件信息失败: %v", err) + } + + return fileInfo, nil +} + +// generateCode 生成6位取件码 +func (fs *FileService) generateCode() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 6) + rand.Read(b) + for i := range b { + b[i] = charset[b[i]%byte(len(charset))] + } + return string(b) +} + +// getContentType 根据文件扩展名获取内容类型 +func (fs *FileService) getContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".pdf": + return "application/pdf" + case ".epub": + return "application/epub+zip" + case ".mobi": + return "application/x-mobipocket-ebook" + case ".txt": + return "text/plain" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".mp4": + return "video/mp4" + case ".avi": + return "video/avi" + case ".mov": + return "video/quicktime" + case ".zip": + return "application/zip" + case ".rar": + return "application/x-rar-compressed" + case ".7z": + return "application/x-7z-compressed" + default: + return "application/octet-stream" + } +} + +// GetFileByCode 根据取件码获取文件信息 +func (fs *FileService) GetFileByCode(code string) (*models.FileInfo, error) { + store := GetStore() + return store.GetFileInfo(code) +} + +// DeleteFile 删除文件 +func (fs *FileService) DeleteFile(code string) error { + fileInfo, err := fs.GetFileByCode(code) + if err != nil { + return err + } + + // 删除物理文件 + if err := os.Remove(fileInfo.FilePath); err != nil { + return fmt.Errorf("删除文件失败: %v", err) + } + + // 从内存存储删除文件信息 + store := GetStore() + store.DeleteFileInfo(code) + + return nil +} + +// ConvertEpubToMobi 将EPUB转换为MOBI格式 +func (fs *FileService) ConvertEpubToMobi(epubPath string) (string, error) { + // TODO: 集成Calibre API进行格式转换 + // 这里暂时返回原文件路径 + return epubPath, fmt.Errorf("格式转换功能尚未实现") +} + +// CleanExpiredFiles 清理过期文件 +func (fs *FileService) CleanExpiredFiles() error { + // TODO: 实现定期清理过期文件的逻辑 + return nil +} diff --git a/internal/services/memory_store.go b/internal/services/memory_store.go new file mode 100644 index 0000000..e13cbbb --- /dev/null +++ b/internal/services/memory_store.go @@ -0,0 +1,61 @@ +package services + +import ( + "fmt" + "sync" + "time" + + "chuan/internal/models" +) + +// 内存存储(生产环境应使用Redis) +type MemoryStore struct { + files map[string]*models.FileInfo + mutex sync.RWMutex +} + +var globalStore = &MemoryStore{ + files: make(map[string]*models.FileInfo), +} + +// StoreFileInfo 存储文件信息 +func (ms *MemoryStore) StoreFileInfo(fileInfo *models.FileInfo) error { + ms.mutex.Lock() + defer ms.mutex.Unlock() + + ms.files[fileInfo.Code] = fileInfo + return nil +} + +// GetFileInfo 获取文件信息 +func (ms *MemoryStore) GetFileInfo(code string) (*models.FileInfo, error) { + ms.mutex.RLock() + defer ms.mutex.RUnlock() + + fileInfo, exists := ms.files[code] + if !exists { + return nil, fmt.Errorf("文件不存在或已过期") + } + + // 检查是否过期 + if time.Now().After(fileInfo.ExpiryTime) { + delete(ms.files, code) + return nil, fmt.Errorf("文件已过期") + } + + return fileInfo, nil +} + +// DeleteFileInfo 删除文件信息 +func (ms *MemoryStore) DeleteFileInfo(code string) error { + ms.mutex.Lock() + defer ms.mutex.Unlock() + + delete(ms.files, code) + return nil +} + +// GetStore 获取全局存储实例 +func GetStore() *MemoryStore { + return globalStore +} diff --git a/internal/services/p2p_service.go b/internal/services/p2p_service.go new file mode 100644 index 0000000..ab47362 --- /dev/null +++ b/internal/services/p2p_service.go @@ -0,0 +1,256 @@ +package services + +import ( + "log" + "math/rand" + "net/http" + "strconv" + "sync" + "time" + + "chuan/internal/models" + + "github.com/gorilla/websocket" +) + +type FileTransferRoom struct { + ID string + Code string // 取件码 + Files []models.FileTransferInfo // 待传输文件信息 + Sender *websocket.Conn // 发送方连接 + Receiver *websocket.Conn // 接收方连接 + CreatedAt time.Time // 创建时间 + mutex sync.RWMutex +} + +type P2PService struct { + rooms map[string]*FileTransferRoom // 使用取件码作为key + roomsMux sync.RWMutex + upgrader websocket.Upgrader +} + +func NewP2PService() *P2PService { + service := &P2PService{ + rooms: make(map[string]*FileTransferRoom), + roomsMux: sync.RWMutex{}, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // 允许所有来源,生产环境应当限制 + }, + }, + } + + // 启动房间清理任务 + go service.cleanupExpiredRooms() + + return service +} + +// CreateRoom 创建新房间并返回取件码 +func (p *P2PService) CreateRoom(files []models.FileTransferInfo) string { + code := generatePickupCode() + + p.roomsMux.Lock() + defer p.roomsMux.Unlock() + + room := &FileTransferRoom{ + ID: "room_" + code, + Code: code, + Files: files, + CreatedAt: time.Now(), + } + + p.rooms[code] = room + log.Printf("创建房间,取件码: %s,文件数量: %d", code, len(files)) + + return code +} + +// GetRoomByCode 根据取件码获取房间 +func (p *P2PService) GetRoomByCode(code string) (*FileTransferRoom, bool) { + p.roomsMux.RLock() + defer p.roomsMux.RUnlock() + + room, exists := p.rooms[code] + return room, exists +} + +// HandleWebSocket 处理WebSocket连接 +func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := p.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket升级失败: %v", err) + return + } + defer conn.Close() + + // 获取取件码和角色 + code := r.URL.Query().Get("code") + role := r.URL.Query().Get("role") // "sender" or "receiver" + + if code == "" || (role != "sender" && role != "receiver") { + log.Printf("缺少取件码或角色参数") + return + } + + // 获取房间 + room, exists := p.GetRoomByCode(code) + if !exists { + log.Printf("房间不存在: %s", code) + return + } + + // 设置连接 + room.mutex.Lock() + if role == "sender" { + room.Sender = conn + log.Printf("发送方连接到房间: %s", code) + } else { + room.Receiver = conn + log.Printf("接收方连接到房间: %s", code) + + // 发送文件列表给接收方 + filesMsg := models.VideoMessage{ + Type: "file-list", + Payload: map[string]interface{}{"files": room.Files}, + } + if err := conn.WriteJSON(filesMsg); err != nil { + 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) + } + } + } + room.mutex.Unlock() // 连接关闭时清理 + defer func() { + room.mutex.Lock() + if role == "sender" { + room.Sender = nil + } else { + room.Receiver = nil + } + room.mutex.Unlock() + + // 如果双方都断开连接,删除房间 + p.cleanupRoom(code) + }() + + // 处理消息 + for { + var msg models.VideoMessage + err := conn.ReadJSON(&msg) + if err != nil { + log.Printf("读取WebSocket消息失败: %v", err) + break + } + + log.Printf("收到WebSocket消息: 类型=%s, 来自=%s, 房间=%s", msg.Type, role, code) + + // 转发消息到对方 + p.forwardMessage(room, role, msg) + } +} + +// forwardMessage 转发消息到对方 +func (p *P2PService) forwardMessage(room *FileTransferRoom, senderRole 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 + targetRole = "receiver" + } else if senderRole == "receiver" && room.Sender != nil { + targetConn = room.Sender + 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) + } +} + +// cleanupRoom 清理房间 +func (p *P2PService) cleanupRoom(code string) { + p.roomsMux.Lock() + defer p.roomsMux.Unlock() + + if room, exists := p.rooms[code]; exists { + room.mutex.RLock() + bothDisconnected := room.Sender == nil && room.Receiver == nil + room.mutex.RUnlock() + + if bothDisconnected { + delete(p.rooms, code) + log.Printf("清理房间: %s", code) + } + } +} + +// cleanupExpiredRooms 定期清理过期房间 +func (p *P2PService) cleanupExpiredRooms() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + p.roomsMux.Lock() + now := time.Now() + for code, room := range p.rooms { + // 房间存在超过1小时则删除 + if now.Sub(room.CreatedAt) > time.Hour { + delete(p.rooms, code) + log.Printf("清理过期房间: %s", code) + } + } + p.roomsMux.Unlock() + } +} + +// generatePickupCode 生成6位取件码 +func generatePickupCode() string { + rand.Seed(time.Now().UnixNano()) + code := rand.Intn(900000) + 100000 + return strconv.Itoa(code) +} + +// GetRoomStats 获取房间统计信息 +func (p *P2PService) GetRoomStats() map[string]interface{} { + p.roomsMux.RLock() + defer p.roomsMux.RUnlock() + + stats := map[string]interface{}{ + "total_rooms": len(p.rooms), + "rooms": make([]map[string]interface{}, 0), + } + + for code, room := range p.rooms { + room.mutex.RLock() + 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, + } + room.mutex.RUnlock() + stats["rooms"] = append(stats["rooms"].([]map[string]interface{}), roomInfo) + } + + return stats +} diff --git a/internal/services/webrtc_service.go b/internal/services/webrtc_service.go new file mode 100644 index 0000000..a52a468 --- /dev/null +++ b/internal/services/webrtc_service.go @@ -0,0 +1,175 @@ +package services + +import ( + "log" + "net/http" + "sync" + + "chuan/internal/models" + + "github.com/gorilla/websocket" +) + +type WebRTCService struct { + clients map[string]*websocket.Conn + clientsMux sync.RWMutex + upgrader websocket.Upgrader +} + +func NewWebRTCService() *WebRTCService { + return &WebRTCService{ + clients: make(map[string]*websocket.Conn), + clientsMux: sync.RWMutex{}, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // 允许所有来源,生产环境应当限制 + }, + }, + } +} + +// HandleWebSocket 处理WebSocket连接 +func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := ws.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket升级失败: %v", err) + return + } + defer conn.Close() + + // 为客户端生成唯一ID + clientID := ws.generateClientID() + + // 添加客户端到连接池 + ws.clientsMux.Lock() + ws.clients[clientID] = conn + ws.clientsMux.Unlock() + + // 连接关闭时清理 + defer func() { + ws.clientsMux.Lock() + delete(ws.clients, clientID) + ws.clientsMux.Unlock() + }() + + // 发送欢迎消息 + welcomeMsg := models.VideoMessage{ + Type: "welcome", + Payload: map[string]string{"clientId": clientID}, + } + ws.sendMessage(conn, welcomeMsg) + + // 处理消息 + for { + var msg models.VideoMessage + err := conn.ReadJSON(&msg) + if err != nil { + log.Printf("读取WebSocket消息失败: %v", err) + break + } + + switch msg.Type { + case "offer": + ws.handleOffer(clientID, msg) + case "answer": + ws.handleAnswer(clientID, msg) + case "ice-candidate": + ws.handleICECandidate(clientID, msg) + case "join-room": + ws.handleJoinRoom(clientID, msg) + case "leave-room": + ws.handleLeaveRoom(clientID, msg) + default: + log.Printf("未知消息类型: %s", msg.Type) + } + } +} + +// handleOffer 处理WebRTC Offer +func (ws *WebRTCService) handleOffer(clientID string, msg models.VideoMessage) { + // 广播offer到其他客户端 + ws.broadcastToOthers(clientID, msg) +} + +// handleAnswer 处理WebRTC Answer +func (ws *WebRTCService) handleAnswer(clientID string, msg models.VideoMessage) { + // 广播answer到其他客户端 + ws.broadcastToOthers(clientID, msg) +} + +// handleICECandidate 处理ICE candidate +func (ws *WebRTCService) handleICECandidate(clientID string, msg models.VideoMessage) { + // 广播ICE candidate到其他客户端 + ws.broadcastToOthers(clientID, msg) +} + +// handleJoinRoom 处理加入房间 +func (ws *WebRTCService) handleJoinRoom(clientID string, msg models.VideoMessage) { + // TODO: 实现房间管理逻辑 + log.Printf("客户端 %s 加入房间", clientID) +} + +// handleLeaveRoom 处理离开房间 +func (ws *WebRTCService) handleLeaveRoom(clientID string, msg models.VideoMessage) { + // TODO: 实现房间管理逻辑 + log.Printf("客户端 %s 离开房间", clientID) +} + +// broadcastToOthers 向其他客户端广播消息 +func (ws *WebRTCService) broadcastToOthers(senderID string, msg models.VideoMessage) { + ws.clientsMux.RLock() + defer ws.clientsMux.RUnlock() + + for clientID, conn := range ws.clients { + if clientID != senderID { + ws.sendMessage(conn, msg) + } + } +} + +// sendMessage 发送消息到WebSocket连接 +func (ws *WebRTCService) sendMessage(conn *websocket.Conn, msg models.VideoMessage) { + if err := conn.WriteJSON(msg); err != nil { + log.Printf("发送WebSocket消息失败: %v", err) + } +} + +// generateClientID 生成客户端ID +func (ws *WebRTCService) generateClientID() string { + // 简单的ID生成,生产环境应使用更安全的方法 + return "client_" + randomString(8) +} + +// CreateOffer 创建WebRTC Offer +func (ws *WebRTCService) CreateOffer() (*models.WebRTCOffer, error) { + // TODO: 实现WebRTC Offer创建 + return &models.WebRTCOffer{ + SDP: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n...", // 示例SDP + Type: "offer", + }, nil +} + +// CreateAnswer 创建WebRTC Answer +func (ws *WebRTCService) CreateAnswer(offer *models.WebRTCOffer) (*models.WebRTCAnswer, error) { + // TODO: 实现WebRTC Answer创建 + return &models.WebRTCAnswer{ + SDP: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n...", // 示例SDP + Type: "answer", + }, nil +} + +// AddICECandidate 添加ICE候选 +func (ws *WebRTCService) AddICECandidate(candidate *models.WebRTCICECandidate) error { + // TODO: 实现ICE候选处理 + return nil +} + +// randomString 生成随机字符串 +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[i%len(charset)] + } + return string(b) +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ba3306e --- /dev/null +++ b/nginx.conf @@ -0,0 +1,181 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基本设置 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # 文件上传限制 + client_max_body_size 64G; + client_body_timeout 60s; + client_header_timeout 60s; + + # 缓存设置 + open_file_cache max=10000 inactive=5m; + open_file_cache_valid 2m; + open_file_cache_min_uses 1; + open_file_cache_errors on; + + # 上游服务器 + upstream chuan_backend { + server chuan:8080; + keepalive 32; + } + + # HTTP服务器(重定向到HTTPS) + server { + listen 80; + server_name _; + return 301 https://$server_name$request_uri; + } + + # HTTPS服务器 + server { + listen 443 ssl http2; + server_name _; + + # SSL配置 + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # 现代SSL配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header Strict-Transport-Security "max-age=63072000" always; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + + # 静态文件缓存 + location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Vary Accept-Encoding; + access_log off; + } + + # 上传文件服务 + location /uploads/ { + alias /var/www/uploads/; + expires 24h; + add_header Cache-Control "public"; + + # 安全设置 + add_header X-Content-Type-Options nosniff; + add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline';"; + + # 限制访问 + valid_referers none blocked server_names; + if ($invalid_referer) { + return 403; + } + } + + # WebSocket代理 + location /ws/ { + proxy_pass http://chuan_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + # API代理 + location /api/ { + proxy_pass http://chuan_backend; + proxy_set_header Host $host; + 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; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # 主应用代理 + location / { + proxy_pass http://chuan_backend; + proxy_set_header Host $host; + 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; + + # 缓存设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # 超时设置 + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # 健康检查 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # 错误页面 + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; + + location = /404.html { + root /usr/share/nginx/html; + } + + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..c8be001 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1,3 @@ +# 上传文件存储目录 + +该目录用于存储用户上传的文件,请保持此文件以确保目录在版本控制中被保留。 diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..20d0b8a --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,357 @@ +/* 自定义样式 - 补充Tailwind CSS */ + +/* 全局样式 */ +* { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.6; +} + +/* 响应式设计优化 */ +@media (max-width: 640px) { + .max-w-7xl { + padding-left: 1rem; + padding-right: 1rem; + } + + nav .flex { + flex-direction: column; + align-items: flex-start; + height: auto; + padding: 1rem 0; + } + + nav .space-x-4 { + margin-top: 1rem; + width: 100%; + display: flex; + justify-content: space-around; + } + + .grid.md\\:grid-cols-2 { + grid-template-columns: 1fr; + gap: 1rem; + } + + .grid.md\\:grid-cols-3 { + grid-template-columns: 1fr; + gap: 1rem; + } + + .text-4xl { + font-size: 2rem; + } + + .text-3xl { + font-size: 1.5rem; + } +} + +@media (max-width: 768px) { + .lg\\:grid-cols-2 { + grid-template-columns: 1fr; + } + + .lg\\:max-w-md { + max-width: 100%; + } +} + +/* 文件拖拽区域样式 */ +#dropZone { + transition: all 0.3s ease; + cursor: pointer; +} + +#dropZone:hover { + border-color: #3b82f6; + background-color: #eff6ff; +} + +#dropZone.drag-over { + border-color: #3b82f6 !important; + background-color: #dbeafe !important; + transform: scale(1.02); +} + +/* 进度条动画 */ +#progressBar { + transition: width 0.3s ease-in-out; +} + +/* 视频元素样式 */ +video { + background-color: #000; + border-radius: 0.5rem; +} + +video::-webkit-media-controls { + display: none !important; +} + +/* 聊天消息滚动条样式 */ +#chatMessages { + scrollbar-width: thin; + scrollbar-color: #cbd5e0 #f7fafc; +} + +#chatMessages::-webkit-scrollbar { + width: 6px; +} + +#chatMessages::-webkit-scrollbar-track { + background: #f7fafc; + border-radius: 3px; +} + +#chatMessages::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 3px; +} + +#chatMessages::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +/* 按钮悬停效果优化 */ +button { + transition: all 0.2s ease-in-out; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button:active { + transform: translateY(0); +} + +/* 卡片悬停效果 */ +.bg-white { + transition: box-shadow 0.3s ease; +} + +.bg-white:hover { + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +/* 加载动画 */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading { + animation: spin 1s linear infinite; +} + +/* 渐入动画 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.5s ease-out; +} + +/* 成功提示动画 */ +@keyframes bounce { + 0%, 20%, 53%, 80%, 100% { + transform: translate3d(0,0,0); + } + 40%, 43% { + transform: translate3d(0, -30px, 0); + } + 70% { + transform: translate3d(0, -15px, 0); + } + 90% { + transform: translate3d(0, -4px, 0); + } +} + +.success-bounce { + animation: bounce 1s ease; +} + +/* 错误提示样式 */ +.error-shake { + animation: shake 0.5s ease-in-out; +} + +@keyframes shake { + 0%, 100% { + transform: translateX(0); + } + 10%, 30%, 50%, 70%, 90% { + transform: translateX(-10px); + } + 20%, 40%, 60%, 80% { + transform: translateX(10px); + } +} + +/* 文件图标样式 */ +.file-icon { + font-size: 2rem; + margin-right: 0.75rem; +} + +/* 状态指示器 */ +.status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} + +.status-connected { + background-color: #10b981; + box-shadow: 0 0 10px #10b981; +} + +.status-connecting { + background-color: #f59e0b; + animation: pulse 2s infinite; +} + +.status-disconnected { + background-color: #ef4444; +} + +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} + +/* 代码块样式 */ +pre, code { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +pre { + padding: 1rem; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* 工具提示样式 */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 120px; + background-color: #555; + color: white; + text-align: center; + border-radius: 6px; + padding: 5px; + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + margin-left: -60px; + opacity: 0; + transition: opacity 0.3s; + font-size: 0.75rem; +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + body { + background-color: #1f2937; + color: #f9fafb; + } + + .bg-white { + background-color: #374151; + color: #f9fafb; + } + + .bg-gray-50 { + background-color: #111827; + } + + .text-gray-600 { + color: #d1d5db; + } + + .text-gray-900 { + color: #f9fafb; + } + + .border-gray-300 { + border-color: #4b5563; + } +} + +/* 打印样式 */ +@media print { + nav, footer, button, .no-print { + display: none !important; + } + + body { + background: white !important; + color: black !important; + } + + .bg-white { + background: white !important; + box-shadow: none !important; + } +} + +/* 高对比度支持 */ +@media (prefers-contrast: high) { + button { + border: 2px solid currentColor; + } + + .border { + border-width: 2px; + } +} + +/* 减少动画支持 */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/static/js/common.js b/web/static/js/common.js new file mode 100644 index 0000000..024ef1e --- /dev/null +++ b/web/static/js/common.js @@ -0,0 +1,393 @@ +// 通用JavaScript工具函数 + +// 工具函数 +const Utils = { + // 格式化文件大小 + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + // 格式化时间 + formatTime(date) { + return new Date(date).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, + + // 生成随机字符串 + randomString(length) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }, + + // 复制到剪贴板 + async copyToClipboard(text) { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } else { + // 兼容性处理 + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const result = document.execCommand('copy'); + document.body.removeChild(textArea); + return result; + } + } catch (error) { + console.error('复制失败:', error); + return false; + } + }, + + // 获取文件类型图标 + getFileIcon(fileName, fileType) { + const ext = fileName.split('.').pop().toLowerCase(); + + // 根据MIME类型 + if (fileType) { + if (fileType.startsWith('image/')) return '🖼️'; + if (fileType.startsWith('video/')) return '🎥'; + if (fileType.startsWith('audio/')) return '🎵'; + if (fileType.includes('pdf')) return '📄'; + if (fileType.includes('text')) return '📝'; + if (fileType.includes('zip') || fileType.includes('rar')) return '📦'; + } + + // 根据文件扩展名 + switch (ext) { + case 'pdf': return '📄'; + case 'doc': + case 'docx': return '📝'; + case 'xls': + case 'xlsx': return '📊'; + case 'ppt': + case 'pptx': return '📈'; + case 'txt': return '📄'; + case 'epub': + case 'mobi': return '📚'; + case 'zip': + case 'rar': + case '7z': return '📦'; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'bmp': return '🖼️'; + case 'mp4': + case 'avi': + case 'mov': + case 'wmv': return '🎥'; + case 'mp3': + case 'wav': + case 'flac': + case 'aac': return '🎵'; + case 'js': + case 'html': + case 'css': + case 'py': + case 'java': + case 'cpp': return '💻'; + default: return '📁'; + } + }, + + // 验证取件码格式 + validateCode(code) { + return /^[A-Z0-9]{6}$/.test(code); + }, + + // 获取浏览器信息 + getBrowserInfo() { + const ua = navigator.userAgent; + let browser = 'Unknown'; + let version = 'Unknown'; + + if (ua.indexOf('Chrome') > -1) { + browser = 'Chrome'; + version = ua.match(/Chrome\/(\d+)/)[1]; + } else if (ua.indexOf('Firefox') > -1) { + browser = 'Firefox'; + version = ua.match(/Firefox\/(\d+)/)[1]; + } else if (ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1) { + browser = 'Safari'; + version = ua.match(/Version\/(\d+)/)[1]; + } else if (ua.indexOf('Edge') > -1) { + browser = 'Edge'; + version = ua.match(/Edge\/(\d+)/)[1]; + } else if (ua.indexOf('360SE') > -1) { + browser = '360浏览器'; + } else if (ua.indexOf('QQBrowser') > -1) { + browser = 'QQ浏览器'; + version = ua.match(/QQBrowser\/(\d+)/)[1]; + } + + return { browser, version }; + }, + + // 检查WebRTC支持 + checkWebRTCSupport() { + return !!(window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection); + }, + + // 检查文件API支持 + checkFileAPISupport() { + return !!(window.File && window.FileReader && window.FileList && window.Blob); + }, + + // 节流函数 + throttle(func, limit) { + let inThrottle; + return function() { + const args = arguments; + const context = this; + if (!inThrottle) { + func.apply(context, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + } + }, + + // 防抖函数 + debounce(func, delay) { + let timeoutId; + return function() { + const args = arguments; + const context = this; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(context, args), delay); + } + } +}; + +// 通知系统 +const Notification = { + // 显示成功消息 + success(message, duration = 3000) { + this.show(message, 'success', duration); + }, + + // 显示错误消息 + error(message, duration = 5000) { + this.show(message, 'error', duration); + }, + + // 显示警告消息 + warning(message, duration = 4000) { + this.show(message, 'warning', duration); + }, + + // 显示信息消息 + info(message, duration = 3000) { + this.show(message, 'info', duration); + }, + + // 显示通知 + show(message, type = 'info', duration = 3000) { + // 创建通知容器(如果不存在) + let container = document.getElementById('notification-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'notification-container'; + container.className = 'fixed top-4 right-4 z-50 space-y-2'; + document.body.appendChild(container); + } + + // 创建通知元素 + const notification = document.createElement('div'); + notification.className = `max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5 fade-in`; + + const bgColor = { + success: 'bg-green-50 border-green-200', + error: 'bg-red-50 border-red-200', + warning: 'bg-yellow-50 border-yellow-200', + info: 'bg-blue-50 border-blue-200' + }[type] || 'bg-gray-50 border-gray-200'; + + const iconEmoji = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️' + }[type] || 'ℹ️'; + + notification.innerHTML = ` +
+
+
+ ${iconEmoji} +
+
+

${message}

+
+
+
+
+ +
+ `; + + notification.className += ` ${bgColor}`; + container.appendChild(notification); + + // 自动移除 + if (duration > 0) { + setTimeout(() => { + if (notification.parentNode) { + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + setTimeout(() => notification.remove(), 300); + } + }, duration); + } + } +}; + +// 加载管理器 +const Loading = { + show(message = '加载中...') { + this.hide(); // 先隐藏现有的加载提示 + + const overlay = document.createElement('div'); + overlay.id = 'loading-overlay'; + overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + + overlay.innerHTML = ` +
+
+ ${message} +
+ `; + + document.body.appendChild(overlay); + }, + + hide() { + const overlay = document.getElementById('loading-overlay'); + if (overlay) { + overlay.remove(); + } + } +}; + +// API请求工具 +const API = { + async request(url, options = {}) { + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const config = { ...defaultOptions, ...options }; + + try { + const response = await fetch(url, config); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } else { + return await response.text(); + } + } catch (error) { + console.error('API请求失败:', error); + throw error; + } + }, + + async get(url, params = {}) { + const urlObj = new URL(url, window.location.origin); + Object.keys(params).forEach(key => + urlObj.searchParams.append(key, params[key]) + ); + + return this.request(urlObj.toString()); + }, + + async post(url, data = {}) { + return this.request(url, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async delete(url) { + return this.request(url, { + method: 'DELETE', + }); + } +}; + +// 页面加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + // 检查浏览器兼容性 + const browserInfo = Utils.getBrowserInfo(); + console.log(`浏览器: ${browserInfo.browser} ${browserInfo.version}`); + + // 检查功能支持 + if (!Utils.checkFileAPISupport()) { + Notification.warning('您的浏览器不完全支持文件API,部分功能可能受限'); + } + + if (!Utils.checkWebRTCSupport()) { + console.warn('浏览器不支持WebRTC,视频功能不可用'); + } + + // 添加全局错误处理 + window.addEventListener('error', function(event) { + console.error('全局错误:', event.error); + Notification.error('页面发生错误,请刷新后重试'); + }); + + // 添加网络状态监听 + window.addEventListener('online', function() { + Notification.success('网络连接已恢复'); + }); + + window.addEventListener('offline', function() { + Notification.warning('网络连接已断开,请检查网络设置'); + }); + + // 添加页面可见性变化监听 + document.addEventListener('visibilitychange', function() { + if (document.hidden) { + console.log('页面已隐藏'); + } else { + console.log('页面已显示'); + } + }); +}); + +// 导出全局对象 +window.Utils = Utils; +window.Notification = Notification; +window.Loading = Loading; +window.API = API; diff --git a/web/static/js/file-transfer.js b/web/static/js/file-transfer.js new file mode 100644 index 0000000..338f90b --- /dev/null +++ b/web/static/js/file-transfer.js @@ -0,0 +1,373 @@ +// 文件传输相关功能 + +// 设置数据通道 +function setupDataChannel(channel) { + dataChannel = channel; + let pendingChunkMeta = null; + + channel.onopen = () => { + console.log('数据通道已打开'); + isP2PConnected = true; + updateP2PStatus(true); + + // 清除连接超时定时器 + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = null; + } + }; + + channel.onmessage = (event) => { + // 检查是否是二进制数据 + if (event.data instanceof ArrayBuffer) { + // 处理二进制数据块 + if (pendingChunkMeta && currentRole === 'receiver') { + receiveFileChunk(pendingChunkMeta, event.data); + pendingChunkMeta = null; + } + } else { + // 处理JSON消息 + try { + const message = JSON.parse(event.data); + if (message.type === 'file-chunk-meta') { + pendingChunkMeta = message; + } else { + handleDataChannelMessage(event.data); + } + } catch (error) { + console.error('解析数据通道消息失败:', error); + } + } + }; + + channel.onerror = (error) => { + console.error('数据通道错误:', error); + isP2PConnected = false; + updateP2PStatus(false); + }; + + channel.onclose = () => { + console.log('数据通道已关闭'); + isP2PConnected = false; + updateP2PStatus(false); + }; +} + +// 更新P2P连接状态 +function updateP2PStatus(connected) { + const receiverStatus = document.getElementById('receiverStatus'); + const downloadButtons = document.querySelectorAll('button[onclick^="downloadFile"]'); + + if (currentRole === 'receiver' && receiverStatus) { + if (connected) { + receiverStatus.innerHTML = ` +
+ + P2P连接已建立,可以下载文件 +
`; + + // 启用下载按钮 + downloadButtons.forEach(btn => { + btn.disabled = false; + btn.classList.remove('opacity-50', 'cursor-not-allowed'); + btn.classList.add('hover:bg-blue-600'); + }); + } else { + receiverStatus.innerHTML = ` +
+ + 正在建立P2P连接... +
`; + + // 禁用下载按钮 + downloadButtons.forEach(btn => { + btn.disabled = true; + btn.classList.add('opacity-50', 'cursor-not-allowed'); + btn.classList.remove('hover:bg-blue-600'); + }); + } + } +} + +// 下载文件 +function downloadFile(fileId) { + if (!isP2PConnected || !dataChannel || dataChannel.readyState !== 'open') { + alert('P2P连接未建立,请等待连接建立后重试'); + return; + } + + // 发送文件请求 + const request = { + type: 'file-request', + fileId: fileId + }; + + dataChannel.send(JSON.stringify(request)); + showTransferProgress(fileId, 'downloading'); +} + +// 处理数据通道消息 +function handleDataChannelMessage(data) { + try { + const message = JSON.parse(data); + + switch (message.type) { + case 'file-request': + if (currentRole === 'sender') { + sendFileData(message.fileId); + } + break; + + case 'file-info': + if (currentRole === 'receiver') { + // 存储文件信息用于下载 + if (!fileTransfers.has(message.fileId)) { + fileTransfers.set(message.fileId, { + chunks: [], + totalSize: message.size, + receivedSize: 0, + fileName: message.name, + mimeType: message.mimeType + }); + } + } + break; + + case 'file-data': + // 旧的file-data类型已被file-chunk-meta + 二进制数据替代 + // 这里保留是为了向后兼容 + if (currentRole === 'receiver') { + receiveFileDataLegacy(message); + } + break; + + case 'file-complete': + if (currentRole === 'receiver') { + completeFileDownload(message.fileId); + } + break; + } + } catch (error) { + console.error('处理数据通道消息失败:', error); + } +} + +// 发送文件数据 +function sendFileData(fileId) { + const fileIndex = parseInt(fileId.split('_')[1]); + const file = selectedFiles[fileIndex]; + + if (!file) return; + + // 首先发送文件元信息 + const fileInfo = { + type: 'file-info', + fileId: fileId, + name: file.name, + size: file.size, + mimeType: file.type, + lastModified: file.lastModified + }; + dataChannel.send(JSON.stringify(fileInfo)); + + const reader = new FileReader(); + const chunkSize = 65536; // 增加到64KB chunks以提高速度 + let offset = 0; + + const sendChunk = () => { + const slice = file.slice(offset, offset + chunkSize); + reader.readAsArrayBuffer(slice); + }; + + reader.onload = (e) => { + const chunk = e.target.result; + + // 使用更高效的方式传输二进制数据 + if (dataChannel.readyState === 'open') { + // 先发送元数据 + const metadata = { + type: 'file-chunk-meta', + fileId: fileId, + offset: offset, + size: chunk.byteLength, + total: file.size, + isLast: offset + chunk.byteLength >= file.size + }; + dataChannel.send(JSON.stringify(metadata)); + + // 再发送二进制数据 + dataChannel.send(chunk); + } + + offset += chunk.byteLength; + + if (offset < file.size) { + // 减少延迟以提高传输速度 + setTimeout(sendChunk, 1); + } else { + dataChannel.send(JSON.stringify({ + type: 'file-complete', + fileId: fileId + })); + } + }; + + sendChunk(); +} + +// 接收文件块(二进制数据) +function receiveFileChunk(meta, chunkData) { + if (!fileTransfers.has(meta.fileId)) { + // 如果没有文件信息,创建默认的 + fileTransfers.set(meta.fileId, { + chunks: [], + totalSize: meta.total, + receivedSize: 0, + fileName: `unknown_file_${meta.fileId}`, + mimeType: 'application/octet-stream' + }); + } + + const transfer = fileTransfers.get(meta.fileId); + transfer.chunks.push(new Uint8Array(chunkData)); + transfer.receivedSize += chunkData.byteLength; + + // 更新总大小(以防文件信息还没收到) + if (transfer.totalSize !== meta.total) { + transfer.totalSize = meta.total; + } + + // 更新进度 + updateTransferProgress(meta.fileId, transfer.receivedSize, transfer.totalSize); + + if (meta.isLast) { + completeFileDownload(meta.fileId); + } +} + +// 接收文件数据(向后兼容的旧版本) +function receiveFileDataLegacy(message) { + if (!fileTransfers.has(message.fileId)) { + // 如果没有文件信息,创建默认的 + fileTransfers.set(message.fileId, { + chunks: [], + totalSize: message.total, + receivedSize: 0, + fileName: `unknown_file_${message.fileId}`, + mimeType: 'application/octet-stream' + }); + } + + const transfer = fileTransfers.get(message.fileId); + transfer.chunks.push(new Uint8Array(message.chunk)); + transfer.receivedSize += message.chunk.length; + + // 更新总大小(以防文件信息还没收到) + if (transfer.totalSize !== message.total) { + transfer.totalSize = message.total; + } + + // 更新进度 + updateTransferProgress(message.fileId, transfer.receivedSize, transfer.totalSize); + + if (message.isLast) { + completeFileDownload(message.fileId); + } +} + +// 完成文件下载 +function completeFileDownload(fileId) { + const transfer = fileTransfers.get(fileId); + if (!transfer) return; + + // 合并所有chunks,使用正确的MIME类型 + const blob = new Blob(transfer.chunks, { type: transfer.mimeType }); + + // 使用正确的文件名 + const fileName = transfer.fileName || `downloaded_file_${fileId}`; + + // 创建下载链接 + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log(`文件下载完成: ${fileName}, 大小: ${formatFileSize(transfer.totalSize)}`); + + // 清理 + fileTransfers.delete(fileId); + hideTransferProgress(fileId); +} + +// 显示传输进度 +function showTransferProgress(fileId, type) { + const progressContainer = document.getElementById('transferProgress'); + const progressList = document.getElementById('progressList'); + + progressContainer.classList.remove('hidden'); + + // 获取文件名 + let fileName = fileId; + if (currentRole === 'receiver') { + // 从接收方文件列表中获取文件名 + const fileIndex = parseInt(fileId.split('_')[1]); + const receiverFilesList = document.getElementById('receiverFilesList'); + const fileItems = receiverFilesList.querySelectorAll('.font-medium'); + if (fileItems[fileIndex]) { + fileName = fileItems[fileIndex].textContent; + } + } else if (currentRole === 'sender') { + // 从发送方文件列表中获取文件名 + const fileIndex = parseInt(fileId.split('_')[1]); + if (selectedFiles[fileIndex]) { + fileName = selectedFiles[fileIndex].name; + } + } + + const progressItem = document.createElement('div'); + progressItem.id = `progress_${fileId}`; + progressItem.className = 'bg-gray-50 p-3 rounded-lg'; + progressItem.innerHTML = ` +
+ ${type === 'downloading' ? '📥 下载' : '📤 上传'}: ${fileName} + 0% +
+
+
+
+ `; + + progressList.appendChild(progressItem); +} + +// 更新传输进度 +function updateTransferProgress(fileId, received, total) { + const progressItem = document.getElementById(`progress_${fileId}`); + if (!progressItem) return; + + const percentage = Math.round((received / total) * 100); + const progressBar = progressItem.querySelector('.bg-blue-600'); + const percentageText = progressItem.querySelector('.text-gray-500'); + + progressBar.style.width = percentage + '%'; + percentageText.textContent = percentage + '%'; +} + +// 隐藏传输进度 +function hideTransferProgress(fileId) { + const progressItem = document.getElementById(`progress_${fileId}`); + if (progressItem) { + progressItem.remove(); + } + + // 如果没有进度项了,隐藏整个进度容器 + const progressList = document.getElementById('progressList'); + if (progressList.children.length === 0) { + document.getElementById('transferProgress').classList.add('hidden'); + } +} diff --git a/web/static/js/p2p-transfer.js b/web/static/js/p2p-transfer.js new file mode 100644 index 0000000..9119132 --- /dev/null +++ b/web/static/js/p2p-transfer.js @@ -0,0 +1,256 @@ +// 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 new file mode 100644 index 0000000..ec76a71 --- /dev/null +++ b/web/static/js/webrtc-connection.js @@ -0,0 +1,374 @@ +// WebSocket和WebRTC连接管理 + +// WebSocket连接 +function connectWebSocket() { + console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode); + + 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); + + websocket = new WebSocket(wsUrl); + + websocket.onopen = () => { + console.log('WebSocket连接已建立'); + isConnecting = false; + updateConnectionStatus(true); + + // 发送方在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; + + // 如果不是正常关闭且还需要连接,尝试重连 + if (event.code !== 1000 && currentPickupCode && !isConnecting) { + console.log('WebSocket异常关闭,5秒后尝试重连'); + setTimeout(() => { + if (currentPickupCode && !websocket && !isConnecting) { + console.log('尝试重新连接WebSocket'); + connectWebSocket(); + } + }, 5000); + } + }; +} + +// 更新连接状态 +function updateConnectionStatus(connected) { + const senderStatus = document.getElementById('senderStatus'); + const receiverStatus = document.getElementById('receiverStatus'); + + if (currentRole === 'sender' && senderStatus) { + senderStatus.innerHTML = connected ? + `
+ + 接收方已连接 +
` : + `
+ + 等待接收方连接... +
`; + } +} + +// 为发送方初始化P2P连接(不立即创建offer) +function initPeerConnectionForSender() { + console.log('为发送方初始化P2P连接(等待接收方就绪)'); + + // 清除之前的超时定时器 + if (connectionTimeout) { + clearTimeout(connectionTimeout); + } + + // 设置连接超时(60秒,合理的超时时间) + connectionTimeout = setTimeout(() => { + console.error('P2P连接超时(60秒)'); + if (peerConnection && !isP2PConnected) { + console.log('关闭超时的P2P连接'); + peerConnection.close(); + peerConnection = null; + updateP2PStatus(false); + alert('P2P连接超时,请检查网络连接并重试'); + } + }, 60000); + + // 使用国内优化的WebRTC配置 + peerConnection = new RTCPeerConnection({ + iceServers: [ + // 阿里云和腾讯STUN服务器 + { urls: 'stun:stun.chat.bilibili.com:3478' }, + { urls: 'stun:stun.voipbuster.com' }, + { urls: 'stun:stun.voipstunt.com' }, + { urls: 'stun:stun.qq.com:3478' }, + // 备用国外服务器 + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ], + iceCandidatePoolSize: 10 + }); + + // 连接状态监听 + peerConnection.onconnectionstatechange = () => { + console.log('P2P连接状态:', peerConnection.connectionState); + if (peerConnection.connectionState === 'connected') { + console.log('P2P连接建立成功'); + isP2PConnected = true; + updateP2PStatus(true); + + // 清除连接超时定时器 + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = null; + } + } else if (peerConnection.connectionState === 'failed') { + console.error('P2P连接失败'); + updateP2PStatus(false); + } + }; + + // ICE连接状态监听 + peerConnection.oniceconnectionstatechange = () => { + console.log('ICE连接状态:', peerConnection.iceConnectionState); + if (peerConnection.iceConnectionState === 'failed') { + console.error('ICE连接失败'); + updateP2PStatus(false); + } + }; + + // 创建数据通道 + dataChannel = peerConnection.createDataChannel('fileTransfer', { + ordered: true + }); + setupDataChannel(dataChannel); + + // 处理ICE候选 + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + console.log('发送ICE候选:', event.candidate.candidate); + sendWebSocketMessage({ + type: 'ice-candidate', + payload: event.candidate + }); + } else { + console.log('ICE候选收集完成'); + } + }; +} + +// 创建offer(发送方专用) +function createOffer() { + if (!peerConnection) { + console.error('PeerConnection未初始化'); + return; + } + + console.log('发送方创建 offer'); + + peerConnection.createOffer().then(offer => { + console.log('Offer 创建成功'); + return peerConnection.setLocalDescription(offer); + }).then(() => { + console.log('本地描述设置成功,发送 offer'); + sendWebSocketMessage({ + type: 'offer', + payload: peerConnection.localDescription + }); + }).catch(error => { + console.error('创建 offer 失败:', error); + }); +} + +// 初始化P2P连接(接收方使用) +function initPeerConnection() { + console.log('接收方初始化P2P连接'); + + // 清除之前的超时定时器 + if (connectionTimeout) { + clearTimeout(connectionTimeout); + } + + // 设置连接超时(60秒) + connectionTimeout = setTimeout(() => { + console.error('P2P连接超时(60秒)'); + if (peerConnection && !isP2PConnected) { + console.log('关闭超时的P2P连接'); + peerConnection.close(); + peerConnection = null; + updateP2PStatus(false); + alert('P2P连接超时,请检查网络连接并重试'); + } + }, 60000); + + // 使用国内优化配置 + peerConnection = new RTCPeerConnection({ + iceServers: [ + // 阿里云和腾讯STUN服务器 + { urls: 'stun:stun.chat.bilibili.com:3478' }, + { urls: 'stun:stun.voipbuster.com' }, + { urls: 'stun:stun.voipstunt.com' }, + { urls: 'stun:stun.qq.com:3478' }, + // 备用国外服务器 + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ], + iceCandidatePoolSize: 10 + }); + + // 连接状态监听 + peerConnection.onconnectionstatechange = () => { + console.log('P2P连接状态:', peerConnection.connectionState); + if (peerConnection.connectionState === 'connected') { + console.log('P2P连接建立成功'); + isP2PConnected = true; + updateP2PStatus(true); + + // 清除连接超时定时器 + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = null; + } + } else if (peerConnection.connectionState === 'failed') { + console.error('P2P连接失败'); + updateP2PStatus(false); + } + }; + + // ICE连接状态监听 + peerConnection.oniceconnectionstatechange = () => { + console.log('ICE连接状态:', peerConnection.iceConnectionState); + if (peerConnection.iceConnectionState === 'failed') { + console.error('ICE连接失败'); + updateP2PStatus(false); + } + }; + + // 处理数据通道 + peerConnection.ondatachannel = (event) => { + console.log('接收到数据通道'); + const channel = event.channel; + setupDataChannel(channel); + }; + + // 处理ICE候选 + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + console.log('发送ICE候选:', event.candidate.candidate); + sendWebSocketMessage({ + type: 'ice-candidate', + payload: event.candidate + }); + } else { + console.log('ICE候选收集完成'); + } + }; +} + +// 处理WebSocket消息 +async function handleWebSocketMessage(message) { + console.log('收到WebSocket消息:', message.type); + + try { + switch (message.type) { + case 'offer': + console.log('处理 offer'); + // 确保接收方的peerConnection已初始化 + if (!peerConnection) { + console.log('接收方peerConnection未初始化,先初始化'); + initPeerConnection(); + // 等待一小段时间让peerConnection完全初始化 + await new Promise(resolve => setTimeout(resolve, 100)); + } + + await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload)); + console.log('远程描述设置成功,创建 answer'); + + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + console.log('本地描述设置成功,发送 answer'); + + sendWebSocketMessage({ + type: 'answer', + payload: answer + }); + break; + + case 'answer': + console.log('处理 answer'); + if (peerConnection) { + await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload)); + console.log('远程 answer 设置成功'); + } else { + console.error('收到answer但peerConnection未初始化'); + } + break; + + case 'ice-candidate': + console.log('处理 ICE 候选:', message.payload.candidate); + if (peerConnection && peerConnection.remoteDescription) { + try { + await peerConnection.addIceCandidate(new RTCIceCandidate(message.payload)); + console.log('ICE 候选添加成功'); + } catch (error) { + console.error('添加ICE候选失败:', error); + } + } else { + console.warn('收到ICE候选但远程描述未设置,暂时缓存'); + } + break; + + case 'file-list': + if (currentRole === 'receiver') { + console.log('接收到文件列表'); + displayReceiverFiles(message.payload.files); + // 接收方在收到文件列表后初始化P2P连接 + if (!peerConnection) { + console.log('初始化接收方P2P连接'); + initPeerConnection(); + } + } + break; + + case 'receiver-ready': + if (currentRole === 'sender') { + console.log('接收方已连接,创建offer'); + // 发送方现在可以创建offer了 + setTimeout(() => { + if (peerConnection && !isP2PConnected) { + createOffer(); + } + }, 500); + } + break; + } + } catch (error) { + console.error('处理WebSocket消息失败:', error); + } +} + +// 发送WebSocket消息 +function sendWebSocketMessage(message) { + if (websocket && websocket.readyState === WebSocket.OPEN) { + websocket.send(JSON.stringify(message)); + } else { + console.warn('WebSocket未连接,无法发送消息:', message.type); + } +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..9a84671 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,80 @@ + + + + + + {{.Title}} - 文件传输系统 + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{template "content" .}} +
+ + + + + + + + + {{template "scripts" .}} + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..b7746df --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,115 @@ +{{define "content"}} +
+
+
+ +
+

P2P文件传输

+

选择文件自动生成取件码,对方输入取件码即可在线下载

+
+ + +
+ +
+

📤 发送文件

+ + +
+
📁
+

点击选择文件或拖拽文件到此处

+

支持多文件选择

+ +
+ + + + + + +
+ + +
+ + +
+

📥 接收文件

+ + +
+
+
+ + +
+
+
+ + + +
+
+ + + +
+
+
+{{end}} + +{{define "scripts"}} + + + + +{{end}} diff --git a/web/templates/upload.html b/web/templates/upload.html new file mode 100644 index 0000000..0c573d9 --- /dev/null +++ b/web/templates/upload.html @@ -0,0 +1,353 @@ +{{define "content"}} +
+ +
+

📤 文件上传

+

+ 支持拖拽上传、多文件批量上传、进度显示。最大支持64TB文件。 +

+
+ + {{if .Success}} + +
+
+
+

文件上传成功!

+ +
+
+

文件名:{{.FileInfo.FileName}}

+

文件大小:{{.FileInfo.FileSize}} 字节

+

上传时间:{{.FileInfo.UploadTime.Format "2006-01-02 15:04:05"}}

+

过期时间:{{.FileInfo.ExpiryTime.Format "2006-01-02 15:04:05"}}

+
+
+ +
+

您的取件码:

+
{{.FileInfo.Code}}
+ +
+ +
+ + ⬇️ 直接下载 + +
+ +
+
+
+ {{end}} + + +
+
+ +
+
+
📁
+

拖拽文件到此处

+

或者点击选择文件

+ + +
+
+ + + + + + +
+ + + +
+ + +
+

📚 Kindle格式转换

+

+ 专为Kindle用户提供EPUB到MOBI格式转换服务。上传EPUB文件后自动转换为MOBI格式。 +

+
+

+ 💡 提示:格式转换功能正在开发中,敬请期待。 +

+
+
+ + +
+

🔧 技术特性

+
+
+

✨ 拖拽上传

+

支持拖拽文件到页面,HTML5 File API优化用户体验。

+
+
+

📊 进度显示

+

实时显示上传进度,支持多文件批量上传。

+
+
+

🔄 断点续传

+

大文件分片上传,网络中断后可继续上传。

+
+
+

🛡️ 安全存储

+

24小时自动清理,保护用户隐私和服务器空间。

+
+
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/video.html b/web/templates/video.html new file mode 100644 index 0000000..f5fc3d1 --- /dev/null +++ b/web/templates/video.html @@ -0,0 +1,571 @@ +{{define "content"}} +
+ +
+

📺 实时视频传输

+

+ 基于WebRTC的P2P视频传输,低延迟高画质,支持多人连接。 +

+
+ + +
+
+
+
+ 未连接 +
+
+ + +
+
+
+ + +
+ +
+

本地视频

+
+ +
+ 📹 点击连接开启摄像头 +
+
+
+ + + +
+
+ + +
+

远程视频

+
+ +
+ 📡 等待远程连接... +
+
+
+
+ + +
+

💬 文字聊天

+
+
聊天消息将显示在这里...
+
+
+ + +
+
+ + +
+

🔧 连接信息

+
+
+ 连接状态: + 未连接 +
+
+ ICE状态: + new +
+
+ 视频编解码器: + - +
+
+ 音频编解码器: + - +
+
+ +
+
+ WebRTC统计信息 +
+ 点击连接后显示详细统计信息... +
+
+
+
+ + +
+

🌐 浏览器兼容性

+

+ 本功能需要支持WebRTC的现代浏览器。推荐使用Chrome、Firefox、Safari、Edge最新版本。 + 部分功能在360浏览器、QQ浏览器等国产浏览器中可能受限。 +

+
+
+{{end}} + +{{define "scripts"}} + +{{end}}