package filestore import ( "bytes" "context" "fmt" "io" "path/filepath" "strings" "time" minioUtil "fwh-toolbox-server/utils/minio" "github.com/google/uuid" "github.com/minio/minio-go/v7" "go.uber.org/zap" ) // AIFileBucket AI 相关文件统一存放的 MinIO bucket。 const AIFileBucket = "fwh-toolbox-ai-file" // AIFileExpireDays 上传文件的过期天数,超过后由 MinIO 生命周期策略自动清理。 const AIFileExpireDays = 7 // FileRecord 文件记录,描述一个已存入 MinIO 的对象。 // Key 是唯一寻址标识(完整 object key),读取时直接使用它。 type FileRecord struct { Key string `json:"key"` // MinIO object key,读取文件时的唯一寻址标识 FileName string `json:"fileName"` // 原始文件名 Size int64 `json:"size"` // 文件大小(字节) CreatedAt int64 `json:"createdAt"` // 创建时间戳(Unix 秒) } // writeFile 将文本内容上传到 MinIO,返回文件记录。 // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName} // 其中 uuid 前缀用于避免同名文件相互覆盖。 // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName} func writeFile(prefix, userId, sessionId, fileName, content string) (*FileRecord, error) { uniqueId := uuid.New().String()[:8] objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", 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, Size: int64(len(content)), CreatedAt: time.Now().Unix(), } zap.L().Info("[FileStore] 文件已保存到MinIO", zap.String("prefix", prefix), zap.String("key", objectKey), zap.String("fileName", fileName), ) return record, nil } // writeBytes 将字节内容上传到 MinIO(支持任意文件类型) // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName} func writeBytes(prefix, userId, sessionId, fileName string, data []byte) (*FileRecord, error) { uniqueId := uuid.New().String()[:8] objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName) reader := bytes.NewReader(data) ctx := context.Background() _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), 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, Size: int64(len(data)), CreatedAt: time.Now().Unix(), } zap.L().Info("[FileStore] 文件已保存到MinIO", zap.String("prefix", prefix), zap.String("key", objectKey), zap.String("fileName", fileName), ) return record, nil } // UploadFile 上传文本内容到 MinIO 的 "upload" 前缀下(用户上传/供 Agent 读取的文件)。 func UploadFile(userId, sessionId, fileName, content string) (*FileRecord, error) { return writeFile("upload", userId, sessionId, fileName, content) } // DownloadFile 上传文本内容到 MinIO 的 "download" 前缀下(Agent 生成/供用户下载的文件)。 func DownloadFile(userId, sessionId, fileName, content string) (*FileRecord, error) { return writeFile("download", userId, sessionId, fileName, content) } // UploadBytes 上传字节内容到 MinIO 的 "upload" 前缀下(支持任意文件类型)。 func UploadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) { return writeBytes("upload", userId, sessionId, fileName, data) } // DownloadBytes 上传字节内容到 MinIO 的 "download" 前缀下(支持任意文件类型)。 func DownloadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) { return writeBytes("download", userId, sessionId, fileName, data) } // ReadFile 按 key 从 MinIO 读取文件文本内容。 func ReadFile(key string) (string, error) { data, err := ReadFileBytes(key) if err != nil { return "", err } return string(data), nil } // ReadFileBytes 按 key 从 MinIO 读取文件原始字节内容。 func ReadFileBytes(key string) ([]byte, error) { ctx := context.Background() obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, key, minio.GetObjectOptions{}) if err != nil { return nil, fmt.Errorf("从MinIO读取文件失败: %w", err) } defer obj.Close() data, err := io.ReadAll(obj) if err != nil { return nil, fmt.Errorf("读取文件内容失败: %w", err) } return data, nil } // HasFile 检查指定 key 的文件是否存在于 MinIO。 // 通过 StatObject 验证对象存在性,出错(含不存在)时返回 false。 func HasFile(key string) bool { if key == "" { return false } _, err := minioUtil.StatObjectFromBucket(AIFileBucket, key) return err == nil } // GetFiles 列出指定范围下的所有文件记录。 // prefix、userId 必填;sessionId 可为空。 // - sessionId 非空:列举 {prefix}/{userId}/{sessionId}/ 下的对象 // - sessionId 为空:列举 {prefix}/{userId}/ 下的对象(跨会话) // // 直接以 MinIO 为数据源递归列举对象。 func GetFiles(prefix, userId, sessionId string) ([]FileRecord, error) { if prefix == "" { return nil, fmt.Errorf("prefix 不能为空") } if userId == "" { return nil, fmt.Errorf("userId 不能为空") } var listPrefix string if sessionId == "" { listPrefix = fmt.Sprintf("%s/%s/", prefix, userId) } else { listPrefix = fmt.Sprintf("%s/%s/%s/", prefix, userId, sessionId) } objects, err := minioUtil.ListFilesObjectFromBucket(AIFileBucket, listPrefix, true) if err != nil { return nil, fmt.Errorf("列举MinIO文件失败: %w", err) } result := make([]FileRecord, 0, len(objects)) for _, obj := range objects { // 从 object key 中还原原始文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符) fileName := filepath.Base(obj.Key) if len(fileName) > 9 { fileName = fileName[9:] } result = append(result, FileRecord{ Key: obj.Key, FileName: fileName, Size: obj.Size, CreatedAt: obj.LastModified.Unix(), }) } return result, nil } // FindKeyByFileName 在 {prefix}/{userId}/{sessionId}/ 下按原始文件名查找最新的 object key。 // // 由于 object key 形如 {prefix}/{userId}/{sessionId}/{uuid}_{fileName},包含随机 uuid, // 无法由 fileName 直接推导。此处通过列举该会话前缀下的对象、匹配还原后的文件名, // 并在多个同名文件时返回创建时间最新的一个(CreatedAt 最大)。 // 找不到时返回空字符串。 func FindKeyByFileName(prefix, userId, sessionId, fileName string) (string, error) { files, err := GetFiles(prefix, userId, sessionId) if err != nil { return "", err } var newestKey string var newestAt int64 = -1 for _, f := range files { if f.FileName == fileName && f.CreatedAt > newestAt { newestKey = f.Key newestAt = f.CreatedAt } } return newestKey, nil } // FindKeyByPath 与 FindKeyByFileName 类似,但接受 Agent 侧使用的路径(如 /uploads/report.csv), // 自动取 basename 作为文件名进行查找。 func FindKeyByPath(prefix, userId, sessionId, path string) (string, error) { return FindKeyByFileName(prefix, userId, sessionId, filepath.Base(path)) } // 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" } }