package handler import ( "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "image" "image/color" "image/jpeg" "image/png" "io" "github.com/yourname/cyrene-ai/pkg/logger" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/yourname/cyrene-ai/gateway/internal/middleware" "github.com/yourname/cyrene-ai/gateway/internal/store" ) // FileHandler 文件管理处理器 type FileHandler struct { store *store.FileStore uploadDir string } // NewFileHandler 创建文件处理器 func NewFileHandler(s *store.FileStore) *FileHandler { return &FileHandler{ store: s, uploadDir: "./uploads", } } // ========== 允许的文件类型 ========== var allowedMimeTypes = map[string]bool{ // 图片 "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, "image/svg+xml": true, // 文档 "application/pdf": true, "text/plain": true, "text/markdown": true, "application/msword": true, "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, // 音频 "audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/webm": true, // 视频 "video/mp4": true, "video/webm": true, } var allowedExtensions = map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".txt": "text/plain", ".md": "text/markdown", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".mp4": "video/mp4", } const maxFileSize = 20 * 1024 * 1024 // 20MB // ========== POST /api/v1/files/upload ========== // Upload 处理文件上传 func (h *FileHandler) Upload(c *gin.Context) { userID := middleware.GetUserID(c) // Nil store guard — 数据库不可用时拒绝上传 if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用,数据库未连接", "errorType": "service_unavailable"}) return } // 获取上传的文件 file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件", "errorType": "missing_file"}) return } defer file.Close() // 检查文件大小 if header.Size > maxFileSize { c.JSON(http.StatusBadRequest, gin.H{ "error": "文件大小超过限制 (最大 20MB)", "errorType": "file_too_large", }) return } // 验证文件类型 mimeType := header.Header.Get("Content-Type") ext := strings.ToLower(filepath.Ext(header.Filename)) if mimeType == "" || mimeType == "application/octet-stream" { // 尝试从扩展名推断 if inferred, ok := allowedExtensions[ext]; ok { mimeType = inferred } } if !allowedMimeTypes[mimeType] { // 再尝试通过扩展名检查 if _, ok := allowedExtensions[ext]; !ok { c.JSON(http.StatusBadRequest, gin.H{ "error": "不支持的文件类型: " + mimeType, "errorType": "unsupported_type", }) return } mimeType = allowedExtensions[ext] } // 安全化文件名:移除路径分隔符和特殊字符 safeFilename := sanitizeFilename(header.Filename) // 生成文件ID (crypto/rand UUID v4) fileID := generateUUID() // 创建按日期组织的目录 dateDir := time.Now().Format("2006-01-02") storedDir := filepath.Join(h.uploadDir, dateDir) if err := os.MkdirAll(storedDir, 0755); err != nil { logger.Printf("[FileHandler] 创建上传目录失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败", "errorType": "server_error"}) return } // 存储路径:以UUID+原始扩展名保存 storedFilename := fileID + ext storedPath := filepath.Join(storedDir, storedFilename) // 计算 SHA256 hash hasher := sha256.New() teeReader := io.TeeReader(file, hasher) // 保存到磁盘 dst, err := os.Create(storedPath) if err != nil { logger.Printf("[FileHandler] 创建文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败", "errorType": "server_error"}) return } defer dst.Close() written, err := io.Copy(dst, teeReader) if err != nil { os.Remove(storedPath) logger.Printf("[FileHandler] 写入文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "写入文件失败", "errorType": "server_error"}) return } hash := hex.EncodeToString(hasher.Sum(nil)) // 去重检查:如果存在相同hash的文件,复用已有记录 if existing, err := h.store.GetFileByHash(hash); err == nil && existing != nil { // 删除刚保存的重复文件 os.Remove(storedPath) logger.Printf("[FileHandler] 文件去重: 复用已有文件 %s (hash=%s)", existing.ID, hash[:16]) c.JSON(http.StatusOK, gin.H{ "id": existing.ID, "filename": existing.Filename, "mime_type": existing.MimeType, "size": existing.Size, "url": fmt.Sprintf("/api/v1/files/%s/download", existing.ID), "dedup": true, }) return } // 创建数据库记录 fileRecord := &store.File{ ID: fileID, UserID: userID, Filename: safeFilename, StoredPath: storedPath, MimeType: mimeType, Size: written, Hash: hash, IsPublic: false, CreatedAt: time.Now(), } if err := h.store.CreateFile(fileRecord); err != nil { os.Remove(storedPath) logger.Printf("[FileHandler] 创建文件记录失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败", "errorType": "db_error"}) return } logger.Printf("[FileHandler] 文件上传成功: %s (%s, %d bytes, hash=%s)", fileID, safeFilename, written, hash[:16]) c.JSON(http.StatusCreated, gin.H{ "id": fileID, "filename": safeFilename, "mime_type": mimeType, "size": written, "url": fmt.Sprintf("/api/v1/files/%s/download", fileID), }) } // ========== GET /api/v1/files ========== // List 列出用户的所有文件 (支持分页) func (h *FileHandler) List(c *gin.Context) { userID := middleware.GetUserID(c) if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) files, total, err := h.store.GetUserFiles(userID, page, limit) if err != nil { logger.Printf("[FileHandler] 查询文件列表失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败", "errorType": "db_error"}) return } // 转换为响应格式 result := make([]gin.H, 0, len(files)) for _, f := range files { item := gin.H{ "id": f.ID, "user_id": f.UserID, "filename": f.Filename, "mime_type": f.MimeType, "size": f.Size, "hash": f.Hash, "is_public": f.IsPublic, "created_at": f.CreatedAt.UnixMilli(), "url": fmt.Sprintf("/api/v1/files/%s/download", f.ID), } // 图片类型添加缩略图URL if isImageType(f.MimeType) { item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID) } result = append(result, item) } c.JSON(http.StatusOK, gin.H{ "files": result, "total": total, "page": page, "limit": limit, }) } // ========== GET /api/v1/files/:id ========== // Get 获取文件元数据 func (h *FileHandler) Get(c *gin.Context) { userID := middleware.GetUserID(c) fileID := c.Param("id") if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } f, err := h.store.GetFile(fileID) if err != nil { logger.Printf("[FileHandler] 查询文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"}) return } if f == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "文件不存在", "errorType": "file_not_found", }) return } // 检查权限 if f.UserID != userID && !f.IsPublic { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"}) return } item := gin.H{ "id": f.ID, "user_id": f.UserID, "filename": f.Filename, "mime_type": f.MimeType, "size": f.Size, "hash": f.Hash, "is_public": f.IsPublic, "created_at": f.CreatedAt.UnixMilli(), "url": fmt.Sprintf("/api/v1/files/%s/download", f.ID), } if isImageType(f.MimeType) { item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID) } c.JSON(http.StatusOK, item) } // ========== GET /api/v1/files/:id/download ========== // Download 下载文件 func (h *FileHandler) Download(c *gin.Context) { userID := middleware.GetUserID(c) fileID := c.Param("id") if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } f, err := h.store.GetFile(fileID) if err != nil { logger.Printf("[FileHandler] 查询文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"}) return } if f == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "文件不存在", "errorType": "file_not_found", }) return } // 检查权限 if f.UserID != userID && !f.IsPublic { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"}) return } // 检查文件在磁盘上是否存在 if _, err := os.Stat(f.StoredPath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{ "error": "文件实体不存在(可能已被清理)", "errorType": "file_missing", }) return } // 设置 Content-Disposition c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.Filename)) c.Header("Content-Type", f.MimeType) c.File(f.StoredPath) } // ========== DELETE /api/v1/files/:id ========== // Delete 删除文件 func (h *FileHandler) Delete(c *gin.Context) { userID := middleware.GetUserID(c) fileID := c.Param("id") if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } f, err := h.store.GetFile(fileID) if err != nil { logger.Printf("[FileHandler] 查询文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"}) return } if f == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "文件不存在", "errorType": "file_not_found", }) return } // 检查权限 if f.UserID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件", "errorType": "access_denied"}) return } // 删除磁盘上的文件(忽略错误,可能已被删除) if err := os.Remove(f.StoredPath); err != nil && !os.IsNotExist(err) { logger.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err) } // 删除数据库记录 if err := h.store.DeleteFile(fileID); err != nil { logger.Printf("[FileHandler] 删除文件记录失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败", "errorType": "db_error"}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // ========== GET /api/v1/files/:id/thumbnail ========== // Thumbnail 返回文件缩略图 func (h *FileHandler) Thumbnail(c *gin.Context) { userID := middleware.GetUserID(c) fileID := c.Param("id") if h.store == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"}) return } f, err := h.store.GetFile(fileID) if err != nil { logger.Printf("[FileHandler] 查询文件失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"}) return } if f == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "文件不存在", "errorType": "file_not_found", }) return } if f.UserID != userID && !f.IsPublic { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"}) return } // 如果是图片,生成缩略图 if isImageType(f.MimeType) && f.MimeType != "image/svg+xml" { if thumbData, contentType, err := generateThumbnail(f.StoredPath, f.MimeType); err == nil { c.Header("Content-Type", contentType) c.Header("Cache-Control", "public, max-age=86400") c.Data(http.StatusOK, contentType, thumbData) return } else { logger.Printf("[FileHandler] 生成缩略图失败: %v", err) } } // 非图片文件或缩略图生成失败,返回占位图标 SVG placeholder := generatePlaceholderSVG(f.MimeType) c.Header("Content-Type", "image/svg+xml") c.Header("Cache-Control", "public, max-age=86400") c.Data(http.StatusOK, "image/svg+xml", []byte(placeholder)) } // ========== 辅助函数 ========== // isImageType 判断是否图片类型 func isImageType(mimeType string) bool { return strings.HasPrefix(mimeType, "image/") } // sanitizeFilename 安全化文件名:移除路径分隔符、特殊字符 var unsafeChars = regexp.MustCompile(`[\\/:*?"<>|]`) func sanitizeFilename(name string) string { // 移除路径分隔符和Windows特殊字符 name = unsafeChars.ReplaceAllString(name, "_") // 限制长度 if len(name) > 255 { ext := filepath.Ext(name) base := name[:255-len(ext)] name = base + ext } // 为空时给默认名 if name == "" || name == "." || name == ".." { name = "unnamed_file" } return name } // generateThumbnail 使用 Go 标准库生成缩略图 (最大 300x300) func generateThumbnail(filePath, mimeType string) ([]byte, string, error) { // 打开源文件 srcFile, err := os.Open(filePath) if err != nil { return nil, "", fmt.Errorf("打开文件失败: %w", err) } defer srcFile.Close() // 解码图像 var srcImg image.Image switch mimeType { case "image/jpeg": srcImg, err = jpeg.Decode(srcFile) case "image/png": srcImg, err = png.Decode(srcFile) default: // 尝试通用解码 srcImg, _, err = image.Decode(srcFile) } if err != nil { return nil, "", fmt.Errorf("解码图像失败: %w", err) } // 计算缩略图尺寸 bounds := srcImg.Bounds() srcW := bounds.Dx() srcH := bounds.Dy() maxDim := 300 newW, newH := srcW, srcH if srcW > maxDim || srcH > maxDim { if srcW > srcH { newW = maxDim newH = srcH * maxDim / srcW } else { newH = maxDim newW = srcW * maxDim / srcH } } if newW < 1 { newW = 1 } if newH < 1 { newH = 1 } // 使用标准库双线性缩放 thumbImg := image.NewRGBA(image.Rect(0, 0, newW, newH)) scaleBilinear(thumbImg, srcImg) // 编码为 JPEG 输出 var buf strings.Builder errWriter := &stringWriter{&buf} if err := jpeg.Encode(errWriter, thumbImg, &jpeg.Options{Quality: 80}); err != nil { return nil, "", fmt.Errorf("编码缩略图失败: %w", err) } return []byte(buf.String()), "image/jpeg", nil } // stringWriter 实现 io.Writer 到 strings.Builder type stringWriter struct { b *strings.Builder } func (w *stringWriter) Write(p []byte) (int, error) { return w.b.Write(p) } // generatePlaceholderSVG 为非图片文件生成占位图标 SVG func generatePlaceholderSVG(mimeType string) string { var icon, color string switch { case strings.HasPrefix(mimeType, "audio/"): icon = "🎵" color = "#8B5CF6" // purple case strings.HasPrefix(mimeType, "video/"): icon = "🎬" color = "#EF4444" // red case strings.HasPrefix(mimeType, "application/pdf"): icon = "📄" color = "#F59E0B" // amber case strings.HasPrefix(mimeType, "text/"): icon = "📝" color = "#3B82F6" // blue default: icon = "📎" color = "#6B7280" // gray } return fmt.Sprintf(` %s %s `, color, color, icon, color, getMimeTypeShort(mimeType)) } // getMimeTypeShort 获取MIME类型简称 func getMimeTypeShort(mimeType string) string { parts := strings.SplitN(mimeType, "/", 2) if len(parts) < 2 { return mimeType } return strings.ToUpper(parts[1]) } // generateUUID 使用 crypto/rand 生成 UUID v4 格式的字符串 func generateUUID() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // 降级方案:基于时间戳 + 随机数的唯一标识 b = make([]byte, 16) ts := time.Now().UnixNano() for i := 0; i < 8; i++ { b[i] = byte(ts >> (i * 8)) } // 用简单 PRNG 填充剩余字节 for i := 8; i < 16; i++ { b[i] = byte((ts * int64(i+1)) % 256) } } // 设置 UUID v4 版本位 (version = 4) b[6] = (b[6] & 0x0f) | 0x40 // 设置 UUID variant 位 (variant = 10xx) b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } // scaleBilinear 使用双线性插值将 src 图像缩放到 dst 的尺寸 (纯标准库实现) func scaleBilinear(dst *image.RGBA, src image.Image) { dstBounds := dst.Bounds() srcBounds := src.Bounds() dstW := dstBounds.Dx() dstH := dstBounds.Dy() srcW := srcBounds.Dx() srcH := srcBounds.Dy() // 计算缩放比例 scaleX := float64(srcW) / float64(dstW) scaleY := float64(srcH) / float64(dstH) for dy := 0; dy < dstH; dy++ { for dx := 0; dx < dstW; dx++ { // 源图像中的浮点坐标 sx := float64(dx)*scaleX + float64(srcBounds.Min.X) sy := float64(dy)*scaleY + float64(srcBounds.Min.Y) // 四个邻近像素的整数坐标 x0 := int(sx) y0 := int(sy) x1 := x0 + 1 y1 := y0 + 1 // 边界限制 if x0 < srcBounds.Min.X { x0 = srcBounds.Min.X } if x1 >= srcBounds.Max.X { x1 = srcBounds.Max.X - 1 } if x0 >= srcBounds.Max.X { x0 = srcBounds.Max.X - 1 } if x1 < srcBounds.Min.X { x1 = srcBounds.Min.X } if y0 < srcBounds.Min.Y { y0 = srcBounds.Min.Y } if y1 >= srcBounds.Max.Y { y1 = srcBounds.Max.Y - 1 } if y0 >= srcBounds.Max.Y { y0 = srcBounds.Max.Y - 1 } if y1 < srcBounds.Min.Y { y1 = srcBounds.Min.Y } // 插值权重 fracX := sx - float64(x0) fracY := sy - float64(y0) // 四个角的 RGBA 值 r00, g00, b00, a00 := src.At(x0, y0).RGBA() r10, g10, b10, a10 := src.At(x1, y0).RGBA() r01, g01, b01, a01 := src.At(x0, y1).RGBA() r11, g11, b11, a11 := src.At(x1, y1).RGBA() // 双线性插值 (在 0-65535 范围内进行) interp := func(c00, c10, c01, c11 uint32) uint8 { top := float64(c00)*(1-fracX) + float64(c10)*fracX bot := float64(c01)*(1-fracX) + float64(c11)*fracX val := top*(1-fracY) + bot*fracY return uint8(val / 256) } r := interp(r00, r10, r01, r11) g := interp(g00, g10, g01, g11) b := interp(b00, b10, b01, b11) a := interp(a00, a10, a01, a11) dst.SetRGBA(dx+int(dstBounds.Min.X), dy+int(dstBounds.Min.Y), color.RGBA{r, g, b, a}) } } }