From dec89435a3dbd3879b5985d317864a5c95a107d0 Mon Sep 17 00:00:00 2001 From: nquidox Date: Wed, 15 Oct 2025 19:46:10 +0300 Subject: [PATCH] merch images crud --- internal/api/merch/controller.go | 159 ++++++++++++++++++++++-- internal/api/merch/dto.go | 4 + internal/api/merch/handler.go | 19 ++- internal/api/merch/repository.go | 14 +++ internal/api/merch/service.go | 204 ++++++++++++++++++++++++++++++- 5 files changed, 387 insertions(+), 13 deletions(-) diff --git a/internal/api/merch/controller.go b/internal/api/merch/controller.go index 2b23c34..f0d8e8b 100644 --- a/internal/api/merch/controller.go +++ b/internal/api/merch/controller.go @@ -1,23 +1,27 @@ package merch import ( + "context" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "merch-parser-api/internal/interfaces" "merch-parser-api/pkg/responses" "net/http" "strings" + "time" ) type controller struct { service *service utils interfaces.Utils + expires time.Duration } -func newController(service *service, utils interfaces.Utils) *controller { +func newController(service *service, utils interfaces.Utils, expires time.Duration) *controller { return &controller{ service: service, utils: utils, + expires: expires, } } @@ -34,6 +38,10 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup, authMW gin.HandlerFunc, ref chartsGroup.GET("", h.controller.getChartsPrices) chartsGroup.GET("/:uuid", h.controller.getDistinctPrices) + imagesGroup := merchGroup.Group("/images") + imagesGroup.POST("/:uuid", h.controller.uploadMerchImage) + imagesGroup.GET("/:uuid", h.controller.getMerchImage) + imagesGroup.DELETE("/:uuid", h.controller.deleteMerchImage) } // @Summary Добавить новый мерч @@ -134,10 +142,10 @@ func (co *controller) getAllMerch(c *gin.Context) { // @Description Обновить информацию про мерч по его uuid в json-е // @Tags Merch // @Security BearerAuth -// @Param body body UpdateMerchDTO true "merch_uuid" +// @Param body body UpdateMerchDTO true "merch_uuid" // @Success 200 -// @Failure 400 {object} responses.ErrorResponse400 -// @Failure 500 {object} responses.ErrorResponse500 +// @Failure 400 {object} responses.ErrorResponse400 +// @Failure 500 {object} responses.ErrorResponse500 // @Router /merch/ [put] func (co *controller) updateMerch(c *gin.Context) { var payload UpdateMerchDTO @@ -227,10 +235,10 @@ func (co *controller) getChartsPrices(c *gin.Context) { // @Tags Merch // @Security BearerAuth // @Param uuid path string true "merch_uuid" -// @Param days query string false "period in days" -// @Success 200 {object} PricesResponse -// @Failure 400 {object} responses.ErrorResponse400 -// @Failure 500 {object} responses.ErrorResponse500 +// @Param days query string false "period in days" +// @Success 200 {object} PricesResponse +// @Failure 400 {object} responses.ErrorResponse400 +// @Failure 500 {object} responses.ErrorResponse500 // @Router /prices/{uuid} [get] func (co *controller) getDistinctPrices(c *gin.Context) { daysQuery := strings.ToLower(c.DefaultQuery("days", "")) @@ -257,3 +265,138 @@ func (co *controller) getDistinctPrices(c *gin.Context) { c.JSON(http.StatusOK, response) } + +// @Summary Загрузить картинки по merch_uuid и query параметрам +// @Description Загрузить картинки по merch_uuid и query параметрам +// @Tags Merch images +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param uuid path string true "Merch UUID" +// @Param file formData file true "Image file" +// @Param imageType formData string true "Image type: thumbnail, full or all" Enums(thumbnail, full, all) +// @Success 200 +// @Failure 400 {object} responses.ErrorResponse400 +// @Failure 500 {object} responses.ErrorResponse500 +// @Router /merch/images/{uuid} [post] +func (co *controller) uploadMerchImage(c *gin.Context) { + userUuid, err := co.utils.GetUserUuidFromContext(c) + if err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to get user uuid from context") + return + } + + merchUuid := c.Param("uuid") + if merchUuid == "" { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "MerchUuid is empty"}) + log.Error("Merch | Failed to get single merch") + return + } + + imageType := c.PostForm("imageType") + types := map[string]struct{}{"thumbnail": {}, "full": {}, "all": {}} + if _, allowed := types[imageType]; !allowed { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "imageType must be one of: thumbnail, full, all"}) + log.WithError(err).Error("Merch | imageType must be one of: thumbnail, full, all") + return + } + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "file is required"}) + log.WithError(err).Error("Merch | File is required") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), co.expires) + defer cancel() + + err = co.service.uploadMerchImage(ctx, userUuid, merchUuid, imageType, file) + if err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to upload merch image") + return + } + + c.Status(http.StatusOK) +} + +// @Summary Получить картинки по merch_uuid и query параметрам +// @Description Получить картинки по merch_uuid и query параметрам +// @Tags Merch images +// @Security BearerAuth +// @Param uuid path string true "merch_uuid" +// @Param type query string true "image type" +// @Success 200 {object} ImageLink +// @Failure 400 {object} responses.ErrorResponse400 +// @Failure 500 {object} responses.ErrorResponse500 +// @Router /merch/images/{uuid} [get] +func (co *controller) getMerchImage(c *gin.Context) { + typeQuery := strings.ToLower(c.Query("type")) + if typeQuery == "" { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "Image type query param is empty"}) + return + } + + userUuid, err := co.utils.GetUserUuidFromContext(c) + if err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to get user uuid from context") + return + } + + merchUuid := c.Param("uuid") + if merchUuid == "" { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "MerchUuid is empty"}) + log.WithError(err).Error("Merch | Failed to get single merch") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), co.expires) + defer cancel() + + link, err := co.service.getMerchImage(ctx, userUuid, merchUuid, typeQuery) + if err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to get merch image") + return + } + c.JSON(http.StatusOK, ImageLink{Link: link.String()}) +} + +// @Summary Удалить (безвозвратно) картинки по merch_uuid и query параметрам +// @Description Удалить (безвозвратно) картинки по merch_uuid и query параметрам +// @Tags Merch images +// @Security BearerAuth +// @Param uuid path string true "merch_uuid" +// @Param type query string true "image type" +// @Success 200 {object} PricesResponse +// @Failure 400 {object} responses.ErrorResponse400 +// @Failure 500 {object} responses.ErrorResponse500 +// @Router /merch/images/{uuid} [delete] +func (co *controller) deleteMerchImage(c *gin.Context) { + userUuid, err := co.utils.GetUserUuidFromContext(c) + if err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to get user uuid from context") + return + } + + merchUuid := c.Param("uuid") + if merchUuid == "" { + c.JSON(http.StatusBadRequest, responses.ErrorResponse400{Error: "MerchUuid is empty"}) + log.WithError(err).Error("Merch | Failed to get single merch") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), co.expires) + defer cancel() + + if err := co.service.deleteMerchImage(ctx, userUuid, merchUuid); err != nil { + c.JSON(http.StatusInternalServerError, responses.ErrorResponse500{Error: err.Error()}) + log.WithError(err).Error("Merch | Failed to delete merch image") + return + } + c.Status(http.StatusOK) +} diff --git a/internal/api/merch/dto.go b/internal/api/merch/dto.go index 6e872a2..3f94688 100644 --- a/internal/api/merch/dto.go +++ b/internal/api/merch/dto.go @@ -53,3 +53,7 @@ type UpdateMerchDTO struct { Origin string `json:"origin"` Link string `json:"link"` } + +type ImageLink struct { + Link string `json:"link"` +} diff --git a/internal/api/merch/handler.go b/internal/api/merch/handler.go index 274ccdf..e61e4e0 100644 --- a/internal/api/merch/handler.go +++ b/internal/api/merch/handler.go @@ -1,8 +1,10 @@ package merch import ( + log "github.com/sirupsen/logrus" "gorm.io/gorm" "merch-parser-api/internal/interfaces" + "time" ) type Handler struct { @@ -14,12 +16,25 @@ type Handler struct { type Deps struct { DB *gorm.DB Utils interfaces.Utils + Media interfaces.MediaStorage } func NewHandler(deps Deps) *Handler { + packageBucketName := "user-merch-images" + expires := time.Minute * 1 + r := NewRepo(deps.DB) - s := newService(r) - c := newController(s, deps.Utils) + s := newService(r, deps.Media, packageBucketName, expires) + c := newController(s, deps.Utils, expires) + + media := deps.Media + log.WithFields(log.Fields{ + "addr": media, + }).Debug("Merch handler constructor | Media provider") + + if err := media.СreateBucketIfNotExists(packageBucketName); err != nil { + log.WithError(err).Fatal("Merch handler constructor | Failed to ensure bucket exists") + } return &Handler{ repo: r, diff --git a/internal/api/merch/repository.go b/internal/api/merch/repository.go index 61a3bc5..3c4ccfc 100644 --- a/internal/api/merch/repository.go +++ b/internal/api/merch/repository.go @@ -21,6 +21,7 @@ func NewRepo(db *gorm.DB) *Repo { type repository interface { addMerch(bundle merchBundle) error + merchRecordExists(userUuid, merchUuid string) (bool, error) getSingleMerch(userUuid, merchUuid string) (merchBundle, error) getAllMerch(userUuid string) ([]ListResponse, error) @@ -54,6 +55,19 @@ func (r *Repo) addMerch(bundle merchBundle) error { return nil } +func (r *Repo) merchRecordExists(userUuid, merchUuid string) (bool, error) { + var exists bool + err := r.db.Raw(` + SELECT EXISTS ( + SELECT 1 + FROM merch + WHERE user_uuid = ? + AND merch_uuid = ? + );`, userUuid, merchUuid).Scan(&exists).Error + + return exists, err +} + func (r *Repo) getSingleMerch(userUuid, merchUuid string) (merchBundle, error) { var merch Merch if err := r.db. diff --git a/internal/api/merch/service.go b/internal/api/merch/service.go index a0fe00f..de751ec 100644 --- a/internal/api/merch/service.go +++ b/internal/api/merch/service.go @@ -1,22 +1,49 @@ package merch import ( + "bytes" + "context" "database/sql" "errors" + "fmt" + "github.com/disintegration/imaging" "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "image" + "image/jpeg" + "io" + "merch-parser-api/internal/interfaces" + "mime/multipart" + "net/url" + "path/filepath" + "strings" "time" ) type service struct { - repo repository + repo repository + media interfaces.MediaStorage + bucketName string + expires time.Duration } -func newService(repo repository) *service { +func newService(repo repository, media interfaces.MediaStorage, bucketName string, expires time.Duration) *service { return &service{ - repo: repo, + repo: repo, + media: media, + bucketName: bucketName, + expires: expires, } } +type uploadImageParams struct { + ctx context.Context + src io.Reader + imageType string + object string + quality int +} + func (s *service) addMerch(payload MerchDTO, userUuid string) error { merchUuid := uuid.NewString() @@ -80,6 +107,13 @@ func (s *service) updateMerch(payload UpdateMerchDTO, userUuid string) error { } func (s *service) deleteMerch(userUuid, merchUuid string) error { + ctx, cancel := context.WithTimeout(context.Background(), s.expires) + defer cancel() + + if err := s.deleteMerchImage(ctx, userUuid, merchUuid); err != nil { + return err + } + return s.repo.deleteMerch(userUuid, merchUuid) } @@ -176,3 +210,167 @@ func (s *service) getDistinctPrices(userUuid, merchUuid, days string) (PricesRes Origins: []OriginWithPrices{originSurugaya, originMandarake}, }, nil } + +func (s *service) uploadMerchImage(ctx context.Context, userUuid, merchUuid, imageType string, file *multipart.FileHeader) error { + exists, err := s.repo.merchRecordExists(userUuid, merchUuid) + if err != nil { + return err + } + + if !exists { + return fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) + } + + rawExt := filepath.Ext(file.Filename) + if rawExt == "" { + return errors.New("no file extension") + } + + ext := strings.ToLower(rawExt[1:]) + allowedTypes := map[string]struct{}{"jpeg": {}, "jpg": {}, "png": {}, "gif": {}} + if _, ok := allowedTypes[ext]; !ok { + return errors.New("invalid file type") + } + + getSrc := func() (io.ReadCloser, error) { + f, err := file.Open() + if err != nil { + log.WithError(err).Error("Merch | Failed to open file") + return nil, err + } + return f, nil + } + + switch imageType { + case "thumbnail": + src, err := getSrc() + if err != nil { + return err + } + return s._uploadToStorage(uploadImageParams{ + ctx: ctx, + src: src, + imageType: "thumbnail", + object: fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid), + quality: 80, + }) + + case "full": + src, err := getSrc() + if err != nil { + return err + } + return s._uploadToStorage(uploadImageParams{ + ctx: ctx, + src: src, + imageType: "full", + object: fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid), + quality: 90, + }) + + case "all": + src, err := getSrc() + if err != nil { + return err + } + if err = s._uploadToStorage(uploadImageParams{ + ctx: ctx, + src: src, + imageType: "thumbnail", + object: fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid), + quality: 80, + }); err != nil { + log.WithError(err).Error("Merch | Upload thumbnail and full image") + return err + } + + src2, err := getSrc() + if err != nil { + return err + } + if err = s._uploadToStorage(uploadImageParams{ + ctx: ctx, + src: src2, + imageType: "full", + object: fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid), + quality: 90, + }); err != nil { + log.WithError(err).Error("Merch | Upload thumbnail and full image") + return err + } + default: + return errors.New("invalid file type") + } + return nil +} + +func (s *service) getMerchImage(ctx context.Context, userUuid, merchUuid, imageType string) (*url.URL, error) { + exists, err := s.repo.merchRecordExists(userUuid, merchUuid) + if err != nil { + return nil, err + } + + if !exists { + return nil, fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) + } + + var object string + switch imageType { + case "thumbnail": + object = fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid) + case "full": + object = fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid) + default: + return nil, fmt.Errorf("unknown image type %s", imageType) + } + + return s.media.Get(ctx, s.bucketName, object, s.expires, nil) +} + +func (s *service) deleteMerchImage(ctx context.Context, userUuid, merchUuid string) error { + exists, err := s.repo.merchRecordExists(userUuid, merchUuid) + if err != nil { + return err + } + + if !exists { + return fmt.Errorf("no merch found for user %s with uuid %s", userUuid, merchUuid) + } + + if err = s.media.Delete(ctx, s.bucketName, fmt.Sprintf("%s/merch/%s/thumbnail.jpg", userUuid, merchUuid)); err != nil { + return err + } + + if err = s.media.Delete(ctx, s.bucketName, fmt.Sprintf("%s/merch/%s/full.jpg", userUuid, merchUuid)); err != nil { + return err + } + + return nil +} + +func (s *service) _uploadToStorage(params uploadImageParams) error { + img, _, err := image.Decode(params.src) + if err != nil { + return fmt.Errorf("failed to decode image: %w", err) + } + + if params.imageType == "thumbnail" { + img = imaging.Resize(img, 300, 300, imaging.Lanczos) + } + + var buf bytes.Buffer + if err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: params.quality}); err != nil { + return fmt.Errorf("failed to encode full image: %w", err) + } + + err = s.media.Upload(params.ctx, s.bucketName, params.object, &buf, -1) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + "img type": "full", + }).Error("Merch | Failed to upload file to media storage") + return err + } + + return nil +}