filestore.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package filestore
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "io"
  7. "path/filepath"
  8. "strings"
  9. "time"
  10. minioUtil "fwh-toolbox-server/utils/minio"
  11. "github.com/google/uuid"
  12. "github.com/minio/minio-go/v7"
  13. "go.uber.org/zap"
  14. )
  15. // AIFileBucket AI 相关文件统一存放的 MinIO bucket。
  16. const AIFileBucket = "fwh-toolbox-ai-file"
  17. // AIFileExpireDays 上传文件的过期天数,超过后由 MinIO 生命周期策略自动清理。
  18. const AIFileExpireDays = 7
  19. // FileRecord 文件记录,描述一个已存入 MinIO 的对象。
  20. // Key 是唯一寻址标识(完整 object key),读取时直接使用它。
  21. type FileRecord struct {
  22. Key string `json:"key"` // MinIO object key,读取文件时的唯一寻址标识
  23. FileName string `json:"fileName"` // 原始文件名
  24. Size int64 `json:"size"` // 文件大小(字节)
  25. CreatedAt int64 `json:"createdAt"` // 创建时间戳(Unix 秒)
  26. }
  27. // writeFile 将文本内容上传到 MinIO,返回文件记录。
  28. // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
  29. // 其中 uuid 前缀用于避免同名文件相互覆盖。
  30. // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
  31. func writeFile(prefix, userId, sessionId, fileName, content string) (*FileRecord, error) {
  32. uniqueId := uuid.New().String()[:8]
  33. objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
  34. // 上传到 MinIO(带7天过期)
  35. reader := strings.NewReader(content)
  36. ctx := context.Background()
  37. _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(content)), minio.PutObjectOptions{
  38. ContentType: DetectContentType(fileName),
  39. Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
  40. })
  41. if err != nil {
  42. return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
  43. }
  44. record := &FileRecord{
  45. Key: objectKey,
  46. FileName: fileName,
  47. Size: int64(len(content)),
  48. CreatedAt: time.Now().Unix(),
  49. }
  50. zap.L().Info("[FileStore] 文件已保存到MinIO",
  51. zap.String("prefix", prefix),
  52. zap.String("key", objectKey),
  53. zap.String("fileName", fileName),
  54. )
  55. return record, nil
  56. }
  57. // writeBytes 将字节内容上传到 MinIO(支持任意文件类型)
  58. // objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
  59. func writeBytes(prefix, userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
  60. uniqueId := uuid.New().String()[:8]
  61. objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
  62. reader := bytes.NewReader(data)
  63. ctx := context.Background()
  64. _, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
  65. ContentType: DetectContentType(fileName),
  66. Expires: time.Now().Add(AIFileExpireDays * 24 * time.Hour),
  67. })
  68. if err != nil {
  69. return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
  70. }
  71. record := &FileRecord{
  72. Key: objectKey,
  73. FileName: fileName,
  74. Size: int64(len(data)),
  75. CreatedAt: time.Now().Unix(),
  76. }
  77. zap.L().Info("[FileStore] 文件已保存到MinIO",
  78. zap.String("prefix", prefix),
  79. zap.String("key", objectKey),
  80. zap.String("fileName", fileName),
  81. )
  82. return record, nil
  83. }
  84. // UploadFile 上传文本内容到 MinIO 的 "upload" 前缀下(用户上传/供 Agent 读取的文件)。
  85. func UploadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
  86. return writeFile("upload", userId, sessionId, fileName, content)
  87. }
  88. // DownloadFile 上传文本内容到 MinIO 的 "download" 前缀下(Agent 生成/供用户下载的文件)。
  89. func DownloadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
  90. return writeFile("download", userId, sessionId, fileName, content)
  91. }
  92. // UploadBytes 上传字节内容到 MinIO 的 "upload" 前缀下(支持任意文件类型)。
  93. func UploadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
  94. return writeBytes("upload", userId, sessionId, fileName, data)
  95. }
  96. // DownloadBytes 上传字节内容到 MinIO 的 "download" 前缀下(支持任意文件类型)。
  97. func DownloadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
  98. return writeBytes("download", userId, sessionId, fileName, data)
  99. }
  100. // ReadFile 按 key 从 MinIO 读取文件文本内容。
  101. func ReadFile(key string) (string, error) {
  102. data, err := ReadFileBytes(key)
  103. if err != nil {
  104. return "", err
  105. }
  106. return string(data), nil
  107. }
  108. // ReadFileBytes 按 key 从 MinIO 读取文件原始字节内容。
  109. func ReadFileBytes(key string) ([]byte, error) {
  110. ctx := context.Background()
  111. obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, key, minio.GetObjectOptions{})
  112. if err != nil {
  113. return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
  114. }
  115. defer obj.Close()
  116. data, err := io.ReadAll(obj)
  117. if err != nil {
  118. return nil, fmt.Errorf("读取文件内容失败: %w", err)
  119. }
  120. return data, nil
  121. }
  122. // HasFile 检查指定 key 的文件是否存在于 MinIO。
  123. // 通过 StatObject 验证对象存在性,出错(含不存在)时返回 false。
  124. func HasFile(key string) bool {
  125. if key == "" {
  126. return false
  127. }
  128. _, err := minioUtil.StatObjectFromBucket(AIFileBucket, key)
  129. return err == nil
  130. }
  131. // GetFiles 列出指定范围下的所有文件记录。
  132. // prefix、userId 必填;sessionId 可为空。
  133. // - sessionId 非空:列举 {prefix}/{userId}/{sessionId}/ 下的对象
  134. // - sessionId 为空:列举 {prefix}/{userId}/ 下的对象(跨会话)
  135. //
  136. // 直接以 MinIO 为数据源递归列举对象。
  137. func GetFiles(prefix, userId, sessionId string) ([]FileRecord, error) {
  138. if prefix == "" {
  139. return nil, fmt.Errorf("prefix 不能为空")
  140. }
  141. if userId == "" {
  142. return nil, fmt.Errorf("userId 不能为空")
  143. }
  144. var listPrefix string
  145. if sessionId == "" {
  146. listPrefix = fmt.Sprintf("%s/%s/", prefix, userId)
  147. } else {
  148. listPrefix = fmt.Sprintf("%s/%s/%s/", prefix, userId, sessionId)
  149. }
  150. objects, err := minioUtil.ListFilesObjectFromBucket(AIFileBucket, listPrefix, true)
  151. if err != nil {
  152. return nil, fmt.Errorf("列举MinIO文件失败: %w", err)
  153. }
  154. result := make([]FileRecord, 0, len(objects))
  155. for _, obj := range objects {
  156. // 从 object key 中还原原始文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符)
  157. fileName := filepath.Base(obj.Key)
  158. if len(fileName) > 9 {
  159. fileName = fileName[9:]
  160. }
  161. result = append(result, FileRecord{
  162. Key: obj.Key,
  163. FileName: fileName,
  164. Size: obj.Size,
  165. CreatedAt: obj.LastModified.Unix(),
  166. })
  167. }
  168. return result, nil
  169. }
  170. // FindKeyByFileName 在 {prefix}/{userId}/{sessionId}/ 下按原始文件名查找最新的 object key。
  171. //
  172. // 由于 object key 形如 {prefix}/{userId}/{sessionId}/{uuid}_{fileName},包含随机 uuid,
  173. // 无法由 fileName 直接推导。此处通过列举该会话前缀下的对象、匹配还原后的文件名,
  174. // 并在多个同名文件时返回创建时间最新的一个(CreatedAt 最大)。
  175. // 找不到时返回空字符串。
  176. func FindKeyByFileName(prefix, userId, sessionId, fileName string) (string, error) {
  177. files, err := GetFiles(prefix, userId, sessionId)
  178. if err != nil {
  179. return "", err
  180. }
  181. var newestKey string
  182. var newestAt int64 = -1
  183. for _, f := range files {
  184. if f.FileName == fileName && f.CreatedAt > newestAt {
  185. newestKey = f.Key
  186. newestAt = f.CreatedAt
  187. }
  188. }
  189. return newestKey, nil
  190. }
  191. // FindKeyByPath 与 FindKeyByFileName 类似,但接受 Agent 侧使用的路径(如 /uploads/report.csv),
  192. // 自动取 basename 作为文件名进行查找。
  193. func FindKeyByPath(prefix, userId, sessionId, path string) (string, error) {
  194. return FindKeyByFileName(prefix, userId, sessionId, filepath.Base(path))
  195. }
  196. // DetectContentType 根据文件扩展名推断 Content-Type
  197. func DetectContentType(fileName string) string {
  198. ext := strings.ToLower(filepath.Ext(fileName))
  199. switch ext {
  200. case ".txt":
  201. return "text/plain; charset=utf-8"
  202. case ".json":
  203. return "application/json"
  204. case ".html", ".htm":
  205. return "text/html"
  206. case ".css":
  207. return "text/css"
  208. case ".js":
  209. return "application/javascript"
  210. case ".ts":
  211. return "application/typescript"
  212. case ".go":
  213. return "text/x-go"
  214. case ".py":
  215. return "text/x-python"
  216. case ".md":
  217. return "text/markdown"
  218. case ".csv":
  219. return "text/csv"
  220. case ".xml":
  221. return "application/xml"
  222. case ".yaml", ".yml":
  223. return "text/yaml"
  224. case ".sh":
  225. return "text/x-shellscript"
  226. case ".vue":
  227. return "text/x-vue"
  228. case ".sql":
  229. return "text/x-sql"
  230. case ".log":
  231. return "text/plain; charset=utf-8"
  232. default:
  233. return "application/octet-stream"
  234. }
  235. }