package storage import ( "bytes" "context" "crypto/rand" "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/rs/zerolog/log" ) type LocalStorage struct { rootDir string metaDir string } func NewLocalStorage(rootDir string) (*LocalStorage, error) { // Ensure the root directory exists if err := os.MkdirAll(rootDir, 0755); err != nil { return nil, fmt.Errorf("failed to create root directory: %w", err) } // Create metadata directory metaDir := filepath.Join(rootDir, ".meta") if err := os.MkdirAll(metaDir, 0755); err != nil { return nil, fmt.Errorf("failed to create metadata directory: %w", err) } return &LocalStorage{ rootDir: rootDir, metaDir: metaDir, }, nil } func (s *LocalStorage) generateID() (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func (s *LocalStorage) generateFilePath(id string, ext string, createTime time.Time) string { // Create year/month directory structure year := createTime.Format("2006") month := createTime.Format("01") // If id already has an extension, don't add ext if filepath.Ext(id) != "" { return filepath.Join(year, month, id) } // Otherwise, add the extension if provided filename := id if ext != "" { filename = id + ext } return filepath.Join(year, month, filename) } func (s *LocalStorage) saveMetadata(id string, info *FileInfo) error { metaPath := filepath.Join(s.metaDir, id+".meta") file, err := os.Create(metaPath) if err != nil { return fmt.Errorf("failed to create metadata file: %w", err) } defer file.Close() data := fmt.Sprintf("%s\n%s", info.Name, info.ContentType) if _, err := file.WriteString(data); err != nil { return fmt.Errorf("failed to write metadata: %w", err) } return nil } func (s *LocalStorage) loadMetadata(id string) (string, string, error) { metaPath := filepath.Join(s.metaDir, id+".meta") data, err := os.ReadFile(metaPath) if err != nil { if os.IsNotExist(err) { return id, "", nil // Return ID as name if metadata doesn't exist } return "", "", fmt.Errorf("failed to read metadata: %w", err) } parts := bytes.Split(data, []byte("\n")) name := string(parts[0]) contentType := "" if len(parts) > 1 { contentType = string(parts[1]) } return name, contentType, nil } func (s *LocalStorage) Save(ctx context.Context, name string, contentType string, reader io.Reader) (*FileInfo, error) { if reader == nil { return nil, fmt.Errorf("reader cannot be nil") } // Generate a unique ID for the file id, err := s.generateID() if err != nil { return nil, fmt.Errorf("failed to generate file ID: %w", err) } // Get file extension from original name or content type ext := filepath.Ext(name) if ext == "" { // If no extension in name, try to get it from content type switch contentType { case "image/jpeg": ext = ".jpg" case "image/png": ext = ".png" case "image/gif": ext = ".gif" case "image/webp": ext = ".webp" case "image/svg+xml": ext = ".svg" case "video/mp4": ext = ".mp4" case "video/webm": ext = ".webm" case "audio/mpeg": ext = ".mp3" case "audio/ogg": ext = ".ogg" case "audio/wav": ext = ".wav" case "application/pdf": ext = ".pdf" case "application/msword": ext = ".doc" case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ext = ".docx" case "application/vnd.ms-excel": ext = ".xls" case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ext = ".xlsx" case "application/zip": ext = ".zip" case "application/x-rar-compressed": ext = ".rar" case "text/plain": ext = ".txt" case "text/csv": ext = ".csv" } } // Create the file path with year/month structure now := time.Now() relPath := s.generateFilePath(id, ext, now) fullPath := filepath.Join(s.rootDir, relPath) // Create directory if it doesn't exist if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return nil, fmt.Errorf("failed to create directory: %w", err) } // Create the file file, err := os.Create(fullPath) if err != nil { return nil, fmt.Errorf("failed to create file: %w", err) } defer file.Close() // Copy the content size, err := io.Copy(file, reader) if err != nil { os.Remove(fullPath) // Clean up on error return nil, fmt.Errorf("failed to write file content: %w", err) } // Save metadata info := &FileInfo{ ID: id, Name: name, ContentType: contentType, Size: size, CreatedAt: now, URL: fmt.Sprintf("/media/%s/%s/%s", now.Format("2006"), now.Format("01"), filepath.Base(relPath)), } if err := s.saveMetadata(id, info); err != nil { os.Remove(fullPath) // Clean up on error return nil, fmt.Errorf("failed to save metadata: %w", err) } return info, nil } func (s *LocalStorage) Get(ctx context.Context, id string) (io.ReadCloser, *FileInfo, error) { // 从 id 中提取文件扩展名和基础 ID ext := filepath.Ext(id) baseID := strings.TrimSuffix(id, ext) // 获取文件的创建时间(从元数据或当前时间) metaPath := filepath.Join(s.metaDir, baseID+".meta") var createTime time.Time if stat, err := os.Stat(metaPath); err == nil { createTime = stat.ModTime() } else { createTime = time.Now() // 如果找不到元数据,使用当前时间 } // 生成完整的文件路径 year := createTime.Format("2006") month := createTime.Format("01") filePath := filepath.Join(s.rootDir, year, month, id) // 直接使用完整的 id(包含扩展名) // 调试日志 log.Debug(). Str("id", id). Str("baseID", baseID). Str("ext", ext). Str("filePath", filePath). Time("createTime", createTime). Msg("Attempting to get file") // 打开文件 file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { return nil, nil, fmt.Errorf("file not found: %s (path: %s)", id, filePath) } return nil, nil, fmt.Errorf("failed to open file: %w", err) } // 获取文件信息 stat, err := file.Stat() if err != nil { file.Close() return nil, nil, fmt.Errorf("failed to get file info: %w", err) } // 加载元数据 name, contentType, err := s.loadMetadata(baseID) if err != nil { file.Close() return nil, nil, err } info := &FileInfo{ ID: id, Name: name, Size: stat.Size(), ContentType: contentType, CreatedAt: createTime, UpdatedAt: stat.ModTime(), URL: fmt.Sprintf("/media/%s/%s/%s", year, month, id), } return file, info, nil } func (s *LocalStorage) Delete(ctx context.Context, id string) error { // 从 id 中提取文件扩展名 ext := filepath.Ext(id) baseID := strings.TrimSuffix(id, ext) // 获取文件的创建时间(从元数据或当前时间) metaPath := filepath.Join(s.metaDir, baseID+".meta") var createTime time.Time if stat, err := os.Stat(metaPath); err == nil { createTime = stat.ModTime() } else { createTime = time.Now() // 如果找不到元数据,使用当前时间 } // 生成完整的文件路径 relPath := s.generateFilePath(baseID, ext, createTime) filePath := filepath.Join(s.rootDir, relPath) // 删除文件 if err := os.Remove(filePath); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to delete file: %w", err) } } // 删除元数据 if err := os.Remove(metaPath); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to delete metadata: %w", err) } } return nil } func (s *LocalStorage) List(ctx context.Context, prefix string, limit int, offset int) ([]*FileInfo, error) { var files []*FileInfo var count int err := filepath.Walk(s.rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // Skip directories and metadata directory if info.IsDir() || path == s.metaDir { if path == s.metaDir { return filepath.SkipDir } return nil } // Get the file ID (basename of the path) id := filepath.Base(path) // Load metadata to get the original name name, contentType, err := s.loadMetadata(id) if err != nil { return err } // Skip files that don't match the prefix if prefix != "" && !strings.HasPrefix(name, prefix) { return nil } // Skip files before offset if count < offset { count++ return nil } // Stop if we've reached the limit if limit > 0 && len(files) >= limit { return filepath.SkipDir } files = append(files, &FileInfo{ ID: id, Name: name, Size: info.Size(), ContentType: contentType, CreatedAt: info.ModTime(), UpdatedAt: info.ModTime(), URL: fmt.Sprintf("/api/media/file/%s", id), }) count++ return nil }) if err != nil { return nil, fmt.Errorf("failed to list files: %w", err) } return files, nil } func (s *LocalStorage) Exists(ctx context.Context, id string) (bool, error) { filePath := filepath.Join(s.rootDir, id) _, err := os.Stat(filePath) if err == nil { return true, nil } if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("failed to check file existence: %w", err) }