filestore.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. package filestore
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "io"
  7. "path/filepath"
  8. "strings"
  9. "sync"
  10. "time"
  11. "github.com/google/uuid"
  12. "github.com/minio/minio-go/v7"
  13. minioUtil "fwh-toolbox-server/utils/minio"
  14. "go.uber.org/zap"
  15. )
  16. const AIFileBucket = "fwh-toolbox-ai-file"
  17. const AIFileExpireDays = 7
  18. // FileRecord 文件记录
  19. type FileRecord struct {
  20. Key string `json:"key"` // MinIO object key
  21. FileName string `json:"fileName"` // 原始文件名
  22. Path string `json:"path"` // Agent 请求的路径
  23. Size int64 `json:"size"` // 文件大小
  24. CreatedAt int64 `json:"createdAt"` // 创建时间戳
  25. }
  26. // FileStore 会话级别的虚拟文件系统
  27. type FileStore struct {
  28. prefix string // "upload" 或 "download",用于区分文件来源
  29. mu sync.RWMutex
  30. files map[string]*FileRecord // path → FileRecord(同路径覆盖更新)
  31. }
  32. // NewFileStore 创建文件存储
  33. // prefix: "upload" 或 "download",对应 MinIO 中的子目录前缀
  34. func NewFileStore(prefix string) *FileStore {
  35. return &FileStore{
  36. prefix: prefix,
  37. files: make(map[string]*FileRecord),
  38. }
  39. }
  40. // WriteFile 将内容上传到 MinIO,返回文件记录
  41. // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
  42. func (fs *FileStore) WriteFile(userId, sessionId, path, content string) (*FileRecord, error) {
  43. fileName := filepath.Base(path)
  44. uniqueId := uuid.New().String()[:8]
  45. objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
  46. // 上传到 MinIO(带7天过期)
  47. reader := strings.NewReader(content)
  48. ctx := context.Background()
  49. _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(content)), minio.PutObjectOptions{
  50. ContentType: DetectContentType(fileName),
  51. Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
  52. })
  53. if err != nil {
  54. return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
  55. }
  56. record := &FileRecord{
  57. Key: objectKey,
  58. FileName: fileName,
  59. Path: path,
  60. Size: int64(len(content)),
  61. CreatedAt: time.Now().Unix(),
  62. }
  63. // 存入内存映射(同路径覆盖)
  64. fs.mu.Lock()
  65. fs.files[path] = record
  66. fs.mu.Unlock()
  67. zap.L().Info("[FileStore] 文件已保存到MinIO",
  68. zap.String("prefix", fs.prefix),
  69. zap.String("path", path),
  70. zap.String("key", objectKey),
  71. zap.String("fileName", fileName),
  72. )
  73. return record, nil
  74. }
  75. // WriteBytes 将字节内容上传到 MinIO(支持二进制文件)
  76. // objectKey 格式: {prefix}/{userId}/{timestamp}_{uuid}_{fileName}
  77. func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentType string) (*FileRecord, error) {
  78. uniqueId := uuid.New().String()[:8]
  79. timestamp := time.Now().Unix()
  80. objectKey := fmt.Sprintf("%s/%s/%d_%s_%s", fs.prefix, userId, timestamp, uniqueId, fileName)
  81. // 虚拟路径(供 Agent readTextFile 使用)
  82. virtualPath := fmt.Sprintf("/uploads/%s", fileName)
  83. reader := strings.NewReader(string(data))
  84. ctx := context.Background()
  85. if contentType == "" {
  86. contentType = DetectContentType(fileName)
  87. }
  88. _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
  89. ContentType: contentType,
  90. Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
  91. })
  92. if err != nil {
  93. return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
  94. }
  95. record := &FileRecord{
  96. Key: objectKey,
  97. FileName: fileName,
  98. Path: virtualPath,
  99. Size: int64(len(data)),
  100. CreatedAt: time.Now().Unix(),
  101. }
  102. // 存入内存映射
  103. fs.mu.Lock()
  104. fs.files[virtualPath] = record
  105. fs.mu.Unlock()
  106. zap.L().Info("[FileStore] 文件已保存到MinIO",
  107. zap.String("prefix", fs.prefix),
  108. zap.String("path", virtualPath),
  109. zap.String("key", objectKey),
  110. zap.String("fileName", fileName),
  111. )
  112. return record, nil
  113. }
  114. // WriteBytesWithSession 将字节内容上传到 MinIO(支持任意文件类型)
  115. // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
  116. func (fs *FileStore) WriteBytesWithSession(userId, sessionId, path string, data []byte) (*FileRecord, error) {
  117. fileName := filepath.Base(path)
  118. uniqueId := uuid.New().String()[:8]
  119. objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
  120. reader := bytes.NewReader(data)
  121. ctx := context.Background()
  122. _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
  123. ContentType: DetectContentType(fileName),
  124. Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
  125. })
  126. if err != nil {
  127. return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
  128. }
  129. record := &FileRecord{
  130. Key: objectKey,
  131. FileName: fileName,
  132. Path: path,
  133. Size: int64(len(data)),
  134. CreatedAt: time.Now().Unix(),
  135. }
  136. fs.mu.Lock()
  137. fs.files[path] = record
  138. fs.mu.Unlock()
  139. zap.L().Info("[FileStore] 文件已保存到MinIO",
  140. zap.String("prefix", fs.prefix),
  141. zap.String("path", path),
  142. zap.String("key", objectKey),
  143. zap.String("fileName", fileName),
  144. )
  145. return record, nil
  146. }
  147. // ReadFile 从 MinIO 读取文件内容
  148. func (fs *FileStore) ReadFile(path string) (string, error) {
  149. data, err := fs.ReadFileBytes(path)
  150. if err != nil {
  151. return "", err
  152. }
  153. return string(data), nil
  154. }
  155. // ReadFileBytes 从 MinIO 读取文件原始字节内容
  156. func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
  157. fs.mu.RLock()
  158. record, ok := fs.files[path]
  159. fs.mu.RUnlock()
  160. if !ok {
  161. return nil, fmt.Errorf("文件不存在: %s", path)
  162. }
  163. ctx := context.Background()
  164. obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, record.Key, minio.GetObjectOptions{})
  165. if err != nil {
  166. return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
  167. }
  168. defer obj.Close()
  169. data, err := io.ReadAll(obj)
  170. if err != nil {
  171. return nil, fmt.Errorf("读取文件内容失败: %w", err)
  172. }
  173. return data, nil
  174. }
  175. // HasFile 检查路径是否有文件记录
  176. func (fs *FileStore) HasFile(path string) bool {
  177. fs.mu.RLock()
  178. defer fs.mu.RUnlock()
  179. _, ok := fs.files[path]
  180. return ok
  181. }
  182. // GetFiles 获取所有文件记录
  183. func (fs *FileStore) GetFiles() []FileRecord {
  184. fs.mu.RLock()
  185. defer fs.mu.RUnlock()
  186. result := make([]FileRecord, 0, len(fs.files))
  187. for _, record := range fs.files {
  188. result = append(result, *record)
  189. }
  190. return result
  191. }
  192. // GetFileByKey 按 MinIO key 查找文件记录
  193. func (fs *FileStore) GetFileByKey(key string) *FileRecord {
  194. fs.mu.RLock()
  195. defer fs.mu.RUnlock()
  196. for _, record := range fs.files {
  197. if record.Key == key {
  198. return record
  199. }
  200. }
  201. return nil
  202. }
  203. // Clear 清除所有文件记录(不删除 MinIO 文件)
  204. func (fs *FileStore) Clear() {
  205. fs.mu.Lock()
  206. fs.files = make(map[string]*FileRecord)
  207. fs.mu.Unlock()
  208. }
  209. // DetectContentType 根据文件扩展名推断 Content-Type
  210. func DetectContentType(fileName string) string {
  211. ext := strings.ToLower(filepath.Ext(fileName))
  212. switch ext {
  213. case ".txt":
  214. return "text/plain; charset=utf-8"
  215. case ".json":
  216. return "application/json"
  217. case ".html", ".htm":
  218. return "text/html"
  219. case ".css":
  220. return "text/css"
  221. case ".js":
  222. return "application/javascript"
  223. case ".ts":
  224. return "application/typescript"
  225. case ".go":
  226. return "text/x-go"
  227. case ".py":
  228. return "text/x-python"
  229. case ".md":
  230. return "text/markdown"
  231. case ".csv":
  232. return "text/csv"
  233. case ".xml":
  234. return "application/xml"
  235. case ".yaml", ".yml":
  236. return "text/yaml"
  237. case ".sh":
  238. return "text/x-shellscript"
  239. case ".vue":
  240. return "text/x-vue"
  241. case ".sql":
  242. return "text/x-sql"
  243. case ".log":
  244. return "text/plain; charset=utf-8"
  245. default:
  246. return "application/octet-stream"
  247. }
  248. }