package httpapi import ( "context" "crypto/md5" "encoding/hex" "io" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/sundynix/pets-be/internal/model" ) func (a *API) fileExists(id string) bool { if !validID(id) { return false } var count int64 a.db.Model(&model.File{}).Where("id = ?", id).Count(&count) return count == 1 } const maxUploadSize = 10 << 20 func (a *API) uploadFile(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+1024) header, err := c.FormFile("file") if err != nil || header.Size <= 0 || header.Size > maxUploadSize { fail(c, http.StatusBadRequest, "请选择不超过 10 MB 的图片") return } reader, err := header.Open() if err != nil { fail(c, http.StatusBadRequest, "读取图片失败") return } defer reader.Close() signature := make([]byte, 512) n, _ := io.ReadFull(reader, signature) contentType := http.DetectContentType(signature[:n]) extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} ext, allowed := extensions[contentType] if !allowed { fail(c, http.StatusBadRequest, "仅支持 JPG、PNG、WebP 图片") return } if _, err := reader.Seek(0, io.SeekStart); err != nil { fail(c, http.StatusBadRequest, "读取图片失败") return } hasher := md5.New() if _, err := io.Copy(hasher, reader); err != nil { fail(c, http.StatusBadRequest, "读取图片失败") return } if _, err := reader.Seek(0, io.SeekStart); err != nil { fail(c, http.StatusBadRequest, "读取图片失败") return } digest := hex.EncodeToString(hasher.Sum(nil)) var existing model.File if a.db.Where("md5 = ?", digest).First(&existing).Error == nil { if existing.Size != header.Size { fail(c, http.StatusConflict, "文件校验冲突") return } ok(c, a.fileView(existing)) return } key := "files/" + digest[:2] + "/" + digest + ext ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second) defer cancel() if err := a.storage.Put(ctx, key, reader, header.Size, contentType); err != nil { fail(c, http.StatusBadGateway, "上传对象存储失败") return } file := model.File{ Bucket: a.storage.Bucket(), ObjectKey: key, MD5: digest, URL: a.storage.PublicURL(key), Name: strings.TrimSpace(header.Filename), ContentType: contentType, Size: header.Size, } if err := a.db.Create(&file).Error; err != nil { if a.db.Where("md5 = ?", digest).First(&existing).Error == nil && existing.Size == header.Size { ok(c, a.fileView(existing)) return } fail(c, http.StatusInternalServerError, "保存文件信息失败") return } created(c, a.fileView(file)) } func (a *API) getFile(c *gin.Context) { if !validID(c.Param("id")) { fail(c, http.StatusBadRequest, "文件 ID 无效") return } var file model.File if err := a.db.Where("id = ?", c.Param("id")).First(&file).Error; err != nil { fail(c, http.StatusNotFound, "文件不存在") return } ok(c, a.fileView(file)) } func (a *API) fileView(file model.File) gin.H { return gin.H{"id": file.ID, "name": file.Name, "content_type": file.ContentType, "size": file.Size, "md5": file.MD5, "url": file.URL} } func (a *API) fileURL(id *string) string { if id == nil || !validID(*id) { return "" } var file model.File if a.db.Select("url").First(&file, "id = ?", *id).Error != nil { return "" } return file.URL } func (a *API) userView(user model.User) gin.H { return gin.H{"id": user.ID, "nickname": user.Nickname, "avatar_file_id": user.AvatarFileID, "avatar_url": a.fileURL(user.AvatarFileID), "created_at": user.CreatedAt} } func (a *API) petView(pet model.Pet) gin.H { var birthDate string if pet.BirthDate != nil { birthDate = pet.BirthDate.Format("2006-01-02") } return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg, "avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID), "coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes, "created_at": pet.CreatedAt} }