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