package filestore import ( "context" "fmt" "io" "path/filepath" "strings" "sync" "time" "github.com/google/uuid" "github.com/minio/minio-go/v7" minioUtil "fwh-toolbox-server/utils/minio" "go.uber.org/zap" ) const AIFileBucket = "fwh-toolbox-ai-file" const AIFileExpireDays = 7 // FileRecord 文件记录 type FileRecord struct { Key string `json:"key"` // MinIO object key FileName string `json:"fileName"` // 原始文件名 Path string `json:"path"` // Agent 请求的路径 Size int64 `json:"size"` // 文件大小 CreatedAt int64 `json:"createdAt"` // 创建时间戳 } // FileStore 会话级别的虚拟文件系统 type FileStore struct { prefix string // "upload" 或 "download",用于区分文件来源 mu sync.RWMutex files map[string]*FileRecord // path → FileRecord(同路径覆盖更新) } // NewFileStore 创建文件存储 // prefix: "upload" 或 "download",对应 MinIO 中的子目录前缀 func NewFileStore(prefix string) *FileStore { return &FileStore{ prefix: prefix, files: make(map[string]*FileRecord), } } // WriteFile 将内容上传到 MinIO,返回文件记录 // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName} func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRecord, error) { fileName := filepath.Base(path) uniqueId := uuid.New().String()[:8] objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName) // 上传到 MinIO(带7天过期) reader := strings.NewReader(content) ctx := context.Background() _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(content)), minio.PutObjectOptions{ ContentType: DetectContentType(fileName), Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour), }) if err != nil { return nil, fmt.Errorf("上传文件到MinIO失败: %w", err) } record := &FileRecord{ Key: objectKey, FileName: fileName, Path: path, Size: int64(len(content)), CreatedAt: time.Now().Unix(), } // 存入内存映射(同路径覆盖) fs.mu.Lock() fs.files[path] = record fs.mu.Unlock() zap.L().Info("[FileStore] 文件已保存到MinIO", zap.String("prefix", fs.prefix), zap.String("path", path), zap.String("key", objectKey), zap.String("fileName", fileName), ) return record, nil } // WriteBytes 将字节内容上传到 MinIO(支持二进制文件) // objectKey 格式: {prefix}/{userId}/{timestamp}_{uuid}_{fileName} func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentType string) (*FileRecord, error) { uniqueId := uuid.New().String()[:8] timestamp := time.Now().Unix() objectKey := fmt.Sprintf("%s/%s/%d_%s_%s", fs.prefix, userId, timestamp, uniqueId, fileName) // 虚拟路径(供 Agent readTextFile 使用) virtualPath := fmt.Sprintf("/uploads/%s", fileName) reader := strings.NewReader(string(data)) ctx := context.Background() if contentType == "" { contentType = DetectContentType(fileName) } _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{ ContentType: contentType, Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour), }) if err != nil { return nil, fmt.Errorf("上传文件到MinIO失败: %w", err) } record := &FileRecord{ Key: objectKey, FileName: fileName, Path: virtualPath, Size: int64(len(data)), CreatedAt: time.Now().Unix(), } // 存入内存映射 fs.mu.Lock() fs.files[virtualPath] = record fs.mu.Unlock() zap.L().Info("[FileStore] 文件已保存到MinIO", zap.String("prefix", fs.prefix), zap.String("path", virtualPath), zap.String("key", objectKey), zap.String("fileName", fileName), ) return record, nil } // ReadFile 从 MinIO 读取文件内容 func (fs *FileStore) ReadFile(path string) (string, error) { fs.mu.RLock() record, ok := fs.files[path] fs.mu.RUnlock() if !ok { return "", fmt.Errorf("文件不存在: %s", path) } ctx := context.Background() obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, record.Key, minio.GetObjectOptions{}) if err != nil { return "", fmt.Errorf("从MinIO读取文件失败: %w", err) } defer obj.Close() data, err := io.ReadAll(obj) if err != nil { return "", fmt.Errorf("读取文件内容失败: %w", err) } return string(data), nil } // HasFile 检查路径是否有文件记录 func (fs *FileStore) HasFile(path string) bool { fs.mu.RLock() defer fs.mu.RUnlock() _, ok := fs.files[path] return ok } // GetFiles 获取所有文件记录 func (fs *FileStore) GetFiles() []FileRecord { fs.mu.RLock() defer fs.mu.RUnlock() result := make([]FileRecord, 0, len(fs.files)) for _, record := range fs.files { result = append(result, *record) } return result } // GetFileByKey 按 MinIO key 查找文件记录 func (fs *FileStore) GetFileByKey(key string) *FileRecord { fs.mu.RLock() defer fs.mu.RUnlock() for _, record := range fs.files { if record.Key == key { return record } } return nil } // Clear 清除所有文件记录(不删除 MinIO 文件) func (fs *FileStore) Clear() { fs.mu.Lock() fs.files = make(map[string]*FileRecord) fs.mu.Unlock() } // DetectContentType 根据文件扩展名推断 Content-Type func DetectContentType(fileName string) string { ext := strings.ToLower(filepath.Ext(fileName)) switch ext { case ".txt": return "text/plain; charset=utf-8" case ".json": return "application/json" case ".html", ".htm": return "text/html" case ".css": return "text/css" case ".js": return "application/javascript" case ".ts": return "application/typescript" case ".go": return "text/x-go" case ".py": return "text/x-python" case ".md": return "text/markdown" case ".csv": return "text/csv" case ".xml": return "application/xml" case ".yaml", ".yml": return "text/yaml" case ".sh": return "text/x-shellscript" case ".vue": return "text/x-vue" case ".sql": return "text/x-sql" case ".log": return "text/plain; charset=utf-8" default: return "application/octet-stream" } }