merch images crud

This commit is contained in:
nquidox 2025-10-15 19:46:10 +03:00
parent e708c92d18
commit dec89435a3
5 changed files with 387 additions and 13 deletions

View file

@ -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)
}

View file

@ -53,3 +53,7 @@ type UpdateMerchDTO struct {
Origin string `json:"origin"`
Link string `json:"link"`
}
type ImageLink struct {
Link string `json:"link"`
}

View file

@ -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,

View file

@ -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.

View file

@ -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
}