|
|
@@ -7,49 +7,37 @@ import (
|
|
|
"io"
|
|
|
"path/filepath"
|
|
|
"strings"
|
|
|
- "sync"
|
|
|
"time"
|
|
|
|
|
|
+ minioUtil "fwh-toolbox-server/utils/minio"
|
|
|
+
|
|
|
"github.com/google/uuid"
|
|
|
"github.com/minio/minio-go/v7"
|
|
|
- minioUtil "fwh-toolbox-server/utils/minio"
|
|
|
"go.uber.org/zap"
|
|
|
)
|
|
|
|
|
|
+// AIFileBucket AI 相关文件统一存放的 MinIO bucket。
|
|
|
const AIFileBucket = "fwh-toolbox-ai-file"
|
|
|
+
|
|
|
+// AIFileExpireDays 上传文件的过期天数,超过后由 MinIO 生命周期策略自动清理。
|
|
|
const AIFileExpireDays = 7
|
|
|
|
|
|
-// FileRecord 文件记录
|
|
|
+// FileRecord 文件记录,描述一个已存入 MinIO 的对象。
|
|
|
+// Key 是唯一寻址标识(完整 object key),读取时直接使用它。
|
|
|
type FileRecord struct {
|
|
|
- Key string `json:"key"` // MinIO object key
|
|
|
+ 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),
|
|
|
- }
|
|
|
+ Size int64 `json:"size"` // 文件大小(字节)
|
|
|
+ CreatedAt int64 `json:"createdAt"` // 创建时间戳(Unix 秒)
|
|
|
}
|
|
|
|
|
|
-// WriteFile 将内容上传到 MinIO,返回文件记录
|
|
|
+// writeFile 将文本内容上传到 MinIO,返回文件记录。
|
|
|
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
|
|
|
+// 其中 uuid 前缀用于避免同名文件相互覆盖。
|
|
|
// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
|
|
|
-func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRecord, error) {
|
|
|
- fileName := filepath.Base(path)
|
|
|
+func writeFile(prefix, userId, sessionId, fileName, content string) (*FileRecord, error) {
|
|
|
uniqueId := uuid.New().String()[:8]
|
|
|
- objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
|
|
|
+ objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
|
|
|
|
|
|
// 上传到 MinIO(带7天过期)
|
|
|
reader := strings.NewReader(content)
|
|
|
@@ -66,19 +54,12 @@ func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRe
|
|
|
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("prefix", prefix),
|
|
|
zap.String("key", objectKey),
|
|
|
zap.String("fileName", fileName),
|
|
|
)
|
|
|
@@ -86,25 +67,17 @@ func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRe
|
|
|
return record, nil
|
|
|
}
|
|
|
|
|
|
-// WriteBytes 将字节内容上传到 MinIO(支持二进制文件)
|
|
|
-// objectKey 格式: {prefix}/{userId}/{timestamp}_{uuid}_{fileName}
|
|
|
-func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentType string) (*FileRecord, error) {
|
|
|
+// writeBytes 将字节内容上传到 MinIO(支持任意文件类型)
|
|
|
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
|
|
|
+func writeBytes(prefix, userId, sessionId, fileName string, data []byte) (*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)
|
|
|
+ objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
|
|
|
|
|
|
- // 虚拟路径(供 Agent readTextFile 使用)
|
|
|
- virtualPath := fmt.Sprintf("/uploads/%s", fileName)
|
|
|
-
|
|
|
- reader := strings.NewReader(string(data))
|
|
|
+ reader := bytes.NewReader(data)
|
|
|
ctx := context.Background()
|
|
|
|
|
|
- if contentType == "" {
|
|
|
- contentType = DetectContentType(fileName)
|
|
|
- }
|
|
|
-
|
|
|
_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
|
|
|
- ContentType: contentType,
|
|
|
+ ContentType: DetectContentType(fileName),
|
|
|
Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
|
|
|
})
|
|
|
if err != nil {
|
|
|
@@ -114,19 +87,12 @@ func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentTyp
|
|
|
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("prefix", prefix),
|
|
|
zap.String("key", objectKey),
|
|
|
zap.String("fileName", fileName),
|
|
|
)
|
|
|
@@ -134,67 +100,39 @@ func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentTyp
|
|
|
return record, nil
|
|
|
}
|
|
|
|
|
|
-// WriteBytesWithSession 将字节内容上传到 MinIO(支持任意文件类型)
|
|
|
-// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
|
|
|
-func (fs *FileStore) WriteBytesWithSession(userId, sessionId, path string, data []byte) (*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)
|
|
|
-
|
|
|
- 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,
|
|
|
- Path: path,
|
|
|
- Size: int64(len(data)),
|
|
|
- CreatedAt: time.Now().Unix(),
|
|
|
- }
|
|
|
+// UploadFile 上传文本内容到 MinIO 的 "upload" 前缀下(用户上传/供 Agent 读取的文件)。
|
|
|
+func UploadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
|
|
|
+ return writeFile("upload", userId, sessionId, fileName, content)
|
|
|
+}
|
|
|
|
|
|
- fs.mu.Lock()
|
|
|
- fs.files[path] = record
|
|
|
- fs.mu.Unlock()
|
|
|
+// DownloadFile 上传文本内容到 MinIO 的 "download" 前缀下(Agent 生成/供用户下载的文件)。
|
|
|
+func DownloadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
|
|
|
+ return writeFile("download", userId, sessionId, fileName, content)
|
|
|
+}
|
|
|
|
|
|
- zap.L().Info("[FileStore] 文件已保存到MinIO",
|
|
|
- zap.String("prefix", fs.prefix),
|
|
|
- zap.String("path", path),
|
|
|
- zap.String("key", objectKey),
|
|
|
- zap.String("fileName", fileName),
|
|
|
- )
|
|
|
+// UploadBytes 上传字节内容到 MinIO 的 "upload" 前缀下(支持任意文件类型)。
|
|
|
+func UploadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
|
|
|
+ return writeBytes("upload", userId, sessionId, fileName, data)
|
|
|
+}
|
|
|
|
|
|
- return record, nil
|
|
|
+// DownloadBytes 上传字节内容到 MinIO 的 "download" 前缀下(支持任意文件类型)。
|
|
|
+func DownloadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
|
|
|
+ return writeBytes("download", userId, sessionId, fileName, data)
|
|
|
}
|
|
|
|
|
|
-// ReadFile 从 MinIO 读取文件内容
|
|
|
-func (fs *FileStore) ReadFile(path string) (string, error) {
|
|
|
- data, err := fs.ReadFileBytes(path)
|
|
|
+// ReadFile 按 key 从 MinIO 读取文件文本内容。
|
|
|
+func ReadFile(key string) (string, error) {
|
|
|
+ data, err := ReadFileBytes(key)
|
|
|
if err != nil {
|
|
|
return "", err
|
|
|
}
|
|
|
return string(data), nil
|
|
|
}
|
|
|
|
|
|
-// ReadFileBytes 从 MinIO 读取文件原始字节内容
|
|
|
-func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
|
|
|
- fs.mu.RLock()
|
|
|
- record, ok := fs.files[path]
|
|
|
- fs.mu.RUnlock()
|
|
|
-
|
|
|
- if !ok {
|
|
|
- return nil, fmt.Errorf("文件不存在: %s", path)
|
|
|
- }
|
|
|
-
|
|
|
+// ReadFileBytes 按 key 从 MinIO 读取文件原始字节内容。
|
|
|
+func ReadFileBytes(key string) ([]byte, error) {
|
|
|
ctx := context.Background()
|
|
|
- obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, record.Key, minio.GetObjectOptions{})
|
|
|
+ obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, key, minio.GetObjectOptions{})
|
|
|
if err != nil {
|
|
|
return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
|
|
|
}
|
|
|
@@ -208,44 +146,86 @@ func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
|
|
|
return data, nil
|
|
|
}
|
|
|
|
|
|
-// HasFile 检查路径是否有文件记录
|
|
|
-func (fs *FileStore) HasFile(path string) bool {
|
|
|
- fs.mu.RLock()
|
|
|
- defer fs.mu.RUnlock()
|
|
|
- _, ok := fs.files[path]
|
|
|
- return ok
|
|
|
+// HasFile 检查指定 key 的文件是否存在于 MinIO。
|
|
|
+// 通过 StatObject 验证对象存在性,出错(含不存在)时返回 false。
|
|
|
+func HasFile(key string) bool {
|
|
|
+ if key == "" {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ _, err := minioUtil.StatObjectFromBucket(AIFileBucket, key)
|
|
|
+ return err == nil
|
|
|
}
|
|
|
|
|
|
-// GetFiles 获取所有文件记录
|
|
|
-func (fs *FileStore) GetFiles() []FileRecord {
|
|
|
- fs.mu.RLock()
|
|
|
- defer fs.mu.RUnlock()
|
|
|
+// 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 不能为空")
|
|
|
+ }
|
|
|
|
|
|
- result := make([]FileRecord, 0, len(fs.files))
|
|
|
- for _, record := range fs.files {
|
|
|
- result = append(result, *record)
|
|
|
+ var listPrefix string
|
|
|
+ if sessionId == "" {
|
|
|
+ listPrefix = fmt.Sprintf("%s/%s/", prefix, userId)
|
|
|
+ } else {
|
|
|
+ listPrefix = fmt.Sprintf("%s/%s/%s/", prefix, userId, sessionId)
|
|
|
}
|
|
|
- return result
|
|
|
+
|
|
|
+ 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
|
|
|
}
|
|
|
|
|
|
-// GetFileByKey 按 MinIO key 查找文件记录
|
|
|
-func (fs *FileStore) GetFileByKey(key string) *FileRecord {
|
|
|
- fs.mu.RLock()
|
|
|
- defer fs.mu.RUnlock()
|
|
|
+// 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
|
|
|
+ }
|
|
|
|
|
|
- for _, record := range fs.files {
|
|
|
- if record.Key == key {
|
|
|
- return record
|
|
|
+ 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 nil
|
|
|
+ return newestKey, nil
|
|
|
}
|
|
|
|
|
|
-// Clear 清除所有文件记录(不删除 MinIO 文件)
|
|
|
-func (fs *FileStore) Clear() {
|
|
|
- fs.mu.Lock()
|
|
|
- fs.files = make(map[string]*FileRecord)
|
|
|
- fs.mu.Unlock()
|
|
|
+// 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
|