| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- 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"
- }
- }
|