-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: Add Contest Thumbnail Upload API (#59) #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,10 +10,15 @@ import ( | |
| commonDto "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/dto" | ||
| "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" | ||
| oauth2Port "github.com/FOR-GAMERS/GAMERS-BE/internal/oauth2/application/port" | ||
| storageDomain "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/domain" | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "log" | ||
| "mime/multipart" | ||
| "time" | ||
|
|
||
| "github.com/google/uuid" | ||
| ) | ||
|
|
||
| // TournamentGeneratorPort defines the interface for tournament generation | ||
|
|
@@ -32,6 +37,7 @@ type ContestService struct { | |
| tournamentGenerator TournamentGeneratorPort | ||
| teamDBPort gamePort.TeamDatabasePort | ||
| gameTeamDBPort gamePort.GameTeamDatabasePort | ||
| storagePort port.ContestStoragePort | ||
| } | ||
|
|
||
| func NewContestService( | ||
|
|
@@ -94,6 +100,11 @@ func NewContestServiceFull( | |
| } | ||
| } | ||
|
|
||
| // SetStoragePort sets the storage port for file upload operations | ||
| func (c *ContestService) SetStoragePort(storagePort port.ContestStoragePort) { | ||
| c.storagePort = storagePort | ||
| } | ||
|
|
||
| func (c *ContestService) SaveContest(req *dto.CreateContestRequest, userId int64) (*domain.Contest, *dto.DiscordLinkRequiredResponse, error) { | ||
| // Check if user has linked Discord account | ||
| discordAccount, err := c.oauth2Repository.FindDiscordAccountByUserId(userId) | ||
|
|
@@ -470,3 +481,68 @@ func (c *ContestService) GetDiscordTextChannels(guildID string) ([]port.DiscordC | |
| } | ||
| return c.discordValidator.GetGuildTextChannels(guildID) | ||
| } | ||
|
|
||
| // UploadThumbnail uploads a thumbnail image for a contest and updates the contest record | ||
| func (c *ContestService) UploadThumbnail(ctx context.Context, contestId, userId int64, file *multipart.FileHeader) (*dto.ThumbnailUploadResponse, error) { | ||
| if c.storagePort == nil { | ||
| return nil, exception.ErrStorageUploadFailed | ||
| } | ||
|
|
||
| // Check leader permission | ||
| if err := c.checkLeaderPermission(contestId, userId); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Validate file | ||
| if err := storageDomain.ValidateFile(file, storageDomain.UploadTypeContestThumbnail); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Generate storage key | ||
| mimeType := file.Header.Get("Content-Type") | ||
| ext := storageDomain.GetExtensionFromMimeType(mimeType) | ||
| key := fmt.Sprintf("%s/%d/%s%s", storageDomain.UploadTypeContestThumbnail, contestId, uuid.New().String(), ext) | ||
|
|
||
| // Upload file to storage | ||
| src, err := file.Open() | ||
| if err != nil { | ||
| return nil, exception.ErrStorageUploadFailed | ||
| } | ||
| defer src.Close() | ||
|
|
||
| if err := c.storagePort.Upload(ctx, key, src, file.Size, mimeType); err != nil { | ||
| return nil, exception.ErrStorageUploadFailed | ||
| } | ||
|
|
||
| // Get public URL and update contest record | ||
| url := c.storagePort.GetPublicURL(key) | ||
|
|
||
| contest, err := c.repository.GetContestById(contestId) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Delete old thumbnail from storage if exists | ||
| if contest.BannerKey != nil && *contest.BannerKey != "" { | ||
| go func() { | ||
| if delErr := c.storagePort.Delete(context.Background(), *contest.BannerKey); delErr != nil { | ||
| log.Printf("[UploadThumbnail] Failed to delete old thumbnail %s: %v", *contest.BannerKey, delErr) | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| contest.Thumbnail = &url | ||
| contest.BannerKey = &key | ||
|
|
||
| if err := c.repository.UpdateContest(contest); err != nil { | ||
| return nil, err | ||
| } | ||
|
Comment on lines
+520
to
+539
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기에 잠재적인 경쟁 조건(race condition)이 있습니다. 동일한 대회에 대한 썸네일 업로드 요청 두 개가 동시에 들어오면, 두 요청 모두 동일한 이전
Comment on lines
+537
to
+539
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if err := c.repository.UpdateContest(contest); err != nil {
// Attempt to delete the newly uploaded file on DB update failure
go func() {
if delErr := c.storagePort.Delete(context.Background(), key); delErr != nil {
log.Printf("[UploadThumbnail] Failed to delete orphaned thumbnail %s after DB error: %v", key, delErr)
}
}()
return nil, err
} |
||
|
|
||
| return &dto.ThumbnailUploadResponse{ | ||
| Key: key, | ||
| URL: url, | ||
| Size: file.Size, | ||
| MimeType: mimeType, | ||
| UploadedAt: time.Now(), | ||
| }, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package port | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| ) | ||
|
|
||
| type ContestStoragePort interface { | ||
| Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) error | ||
| Delete(ctx context.Context, key string) error | ||
| GetPublicURL(key string) string | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ func (c *ContestController) RegisterRoute() { | |
| privateGroup.GET("/me", c.GetMyContests) | ||
| privateGroup.PATCH("/:id", c.UpdateContest) | ||
| privateGroup.DELETE("/:id", c.DeleteContest) | ||
| privateGroup.POST("/:id/thumbnail", c.UploadThumbnail) | ||
| privateGroup.POST("/:id/start", c.StartContest) | ||
| privateGroup.POST("/:id/stop", c.StopContest) | ||
|
|
||
|
|
@@ -263,6 +264,43 @@ func (c *ContestController) StopContest(ctx *gin.Context) { | |
| c.helper.RespondOK(ctx, contest, err, "contest stopped successfully") | ||
| } | ||
|
|
||
| // UploadThumbnail godoc | ||
| // @Summary Upload a contest thumbnail image | ||
| // @Description Upload a thumbnail image for a contest. Maximum file size is 5MB. Allowed formats: jpeg, png, webp. Only the contest leader can upload. | ||
| // @Tags contests | ||
| // @Accept multipart/form-data | ||
| // @Produce json | ||
| // @Security BearerAuth | ||
| // @Param id path int true "Contest ID" | ||
| // @Param file formData file true "Image file (max 5MB, jpeg/png/webp)" | ||
| // @Success 201 {object} response.Response{data=dto.ThumbnailUploadResponse} | ||
| // @Failure 400 {object} response.Response | ||
| // @Failure 401 {object} response.Response | ||
| // @Failure 403 {object} response.Response | ||
| // @Router /api/contests/{id}/thumbnail [post] | ||
| func (c *ContestController) UploadThumbnail(ctx *gin.Context) { | ||
| id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) | ||
| if err != nil { | ||
| response.JSON(ctx, response.BadRequest("invalid contest id")) | ||
| return | ||
| } | ||
|
|
||
| userId, ok := middleware.GetUserIdFromContext(ctx) | ||
| if !ok { | ||
| response.JSON(ctx, response.Error(401, "user not authenticated")) | ||
| return | ||
| } | ||
|
|
||
| file, err := ctx.FormFile("file") | ||
| if err != nil { | ||
| response.JSON(ctx, response.BadRequest("file is required")) | ||
| return | ||
| } | ||
|
|
||
| result, err := c.service.UploadThumbnail(ctx.Request.Context(), id, userId, file) | ||
| c.helper.RespondCreated(ctx, result, err, "thumbnail uploaded successfully") | ||
| } | ||
|
Comment on lines
+281
to
+302
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 이 메소드를 func (c *ContestController) UploadThumbnail(ctx *gin.Context) {
HandleUploadThumbnail(ctx, c.service, c.helper)
} |
||
|
|
||
| // GetMyContests godoc | ||
| // @Summary Get contests I have joined | ||
| // @Description Get all contests that the authenticated user has joined with pagination, sorting, and filtering support | ||
|
|
@@ -421,3 +459,26 @@ func HandleStopContest(ctx *gin.Context, service *application.ContestService, he | |
| contest, err := service.StopContest(ctx.Request.Context(), id, userId) | ||
| helper.RespondOK(ctx, contest, err, "contest stopped successfully") | ||
| } | ||
|
|
||
| func HandleUploadThumbnail(ctx *gin.Context, service *application.ContestService, helper *handler.ControllerHelper) { | ||
| id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) | ||
| if err != nil { | ||
| response.JSON(ctx, response.BadRequest("invalid contest id")) | ||
| return | ||
| } | ||
|
|
||
| userId, ok := middleware.GetUserIdFromContext(ctx) | ||
| if !ok { | ||
| response.JSON(ctx, response.Error(401, "user not authenticated")) | ||
| return | ||
| } | ||
|
|
||
| file, err := ctx.FormFile("file") | ||
| if err != nil { | ||
| response.JSON(ctx, response.BadRequest("file is required")) | ||
| return | ||
| } | ||
|
|
||
| result, err := service.UploadThumbnail(ctx.Request.Context(), id, userId, file) | ||
| helper.RespondCreated(ctx, result, err, "thumbnail uploaded successfully") | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BannerKey필드가 썸네일의 키를 저장하는 데 사용되고 있습니다. 이 기능은 배너가 아닌 썸네일에 관한 것이므로 이는 오해의 소지가 있고 혼란을 야기할 수 있습니다.ThumbnailKey와 같이 더 적절한 이름을 사용하는 것이 좋습니다. 코드 명확성과 유지보수성을 향상시키기 위해Contest도메인 객체 및 이 필드가 사용되는 다른 모든 곳에서 필드 이름을 변경하는 것을 권장합니다.