Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ func main() {
// Storage module - provides R2 storage integration for images
storageDeps := storage.ProvideStorageDependencies(appRouter)

// Set storage port for contest thumbnail upload
if storageDeps != nil {
contestDeps.ContestService.SetStoragePort(storageDeps.StoragePort)
}

// Banner module - provides main banner management for homepage
bannerDeps := banner.ProvideBannerDependencies(db, appRouter)

Expand Down
76 changes: 76 additions & 0 deletions internal/contest/application/contest_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +37,7 @@ type ContestService struct {
tournamentGenerator TournamentGeneratorPort
teamDBPort gamePort.TeamDatabasePort
gameTeamDBPort gamePort.GameTeamDatabasePort
storagePort port.ContestStoragePort
}

func NewContestService(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

BannerKey 필드가 썸네일의 키를 저장하는 데 사용되고 있습니다. 이 기능은 배너가 아닌 썸네일에 관한 것이므로 이는 오해의 소지가 있고 혼란을 야기할 수 있습니다. ThumbnailKey와 같이 더 적절한 이름을 사용하는 것이 좋습니다. 코드 명확성과 유지보수성을 향상시키기 위해 Contest 도메인 객체 및 이 필드가 사용되는 다른 모든 곳에서 필드 이름을 변경하는 것을 권장합니다.


if err := c.repository.UpdateContest(contest); err != nil {
return nil, err
}
Comment on lines +520 to +539
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

여기에 잠재적인 경쟁 조건(race condition)이 있습니다. 동일한 대회에 대한 썸네일 업로드 요청 두 개가 동시에 들어오면, 두 요청 모두 동일한 이전 BannerKey를 읽을 수 있으며, 마지막 DB 업데이트가 이기게 되어 스토리지에 고아 파일(orphaned file)이 남을 수 있습니다. contest 객체에 대한 읽기-수정-쓰기 작업(GetContestById부터 UpdateContest까지)은 이를 방지하기 위해 데이터베이스 트랜잭션 내에서 원자적으로 수행되어야 합니다. 일반적으로 이는 행을 잠그기 위해 SELECT ... FOR UPDATE를 사용하는 것을 포함합니다.

Comment on lines +537 to +539
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

UpdateContest가 실패하면 새 썸네일은 이미 스토리지에 업로드되었지만 데이터베이스 레코드는 업데이트되지 않습니다. 이로 인해 스토리지에 고아 파일이 남게 됩니다. 스토리지 작업을 롤백하기 위해 새로 업로드된 파일을 삭제하여 이 경우를 처리해야 합니다.

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
}
9 changes: 9 additions & 0 deletions internal/contest/application/dto/contest_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ func ToMyContestResponses(contests []*port.ContestWithMembership) []*MyContestRe
return responses
}

// ThumbnailUploadResponse represents the response after uploading a contest thumbnail
type ThumbnailUploadResponse struct {
Key string `json:"key"`
URL string `json:"url"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
UploadedAt time.Time `json:"uploaded_at"`
}

// ChangeMemberRoleRequest represents the request to change a member's role
type ChangeMemberRoleRequest struct {
MemberType domain.MemberType `json:"member_type" binding:"required"`
Expand Down
12 changes: 12 additions & 0 deletions internal/contest/application/port/contest_storage_port.go
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
}
61 changes: 61 additions & 0 deletions internal/contest/presentation/contest_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

UploadThumbnail 메소드의 로직은 파일 끝에 있는 HandleUploadThumbnail 함수와 중복됩니다. 이는 DRY(Don't Repeat Yourself) 원칙을 위반하며 코드를 유지보수하기 어렵게 만듭니다. 이 파일의 기존 패턴은 로직을 포함하는 '테스트 가능한' 핸들러 함수를 두고 컨트롤러 메소드가 이를 호출하는 것으로 보입니다. 중복을 제거하기 위해 이 패턴을 따르는 것이 좋습니다.

이 메소드를 HandleUploadThumbnail을 호출하도록 리팩토링하세요.

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
Expand Down Expand Up @@ -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")
}
16 changes: 10 additions & 6 deletions internal/storage/domain/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import (
type UploadType string

const (
UploadTypeContestBanner UploadType = "contest-banners"
UploadTypeUserProfile UploadType = "user-profiles"
UploadTypeMainBanner UploadType = "main-banners"
UploadTypeContestBanner UploadType = "contest-banners"
UploadTypeContestThumbnail UploadType = "contest-thumbnails"
UploadTypeUserProfile UploadType = "user-profiles"
UploadTypeMainBanner UploadType = "main-banners"
)

const (
MaxContestBannerSize = 5 * 1024 * 1024 // 5MB
MaxUserProfileSize = 2 * 1024 * 1024 // 2MB
MaxMainBannerSize = 5 * 1024 * 1024 // 5MB
MaxContestBannerSize = 5 * 1024 * 1024 // 5MB
MaxContestThumbnailSize = 5 * 1024 * 1024 // 5MB
MaxUserProfileSize = 2 * 1024 * 1024 // 2MB
MaxMainBannerSize = 5 * 1024 * 1024 // 5MB
)

var AllowedMimeTypes = map[string]bool{
Expand Down Expand Up @@ -74,6 +76,8 @@ func getMaxSize(uploadType UploadType) int64 {
switch uploadType {
case UploadTypeContestBanner:
return MaxContestBannerSize
case UploadTypeContestThumbnail:
return MaxContestThumbnailSize
case UploadTypeUserProfile:
return MaxUserProfileSize
case UploadTypeMainBanner:
Expand Down
3 changes: 3 additions & 0 deletions internal/storage/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler"
"github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router"
"github.com/FOR-GAMERS/GAMERS-BE/internal/storage/application"
"github.com/FOR-GAMERS/GAMERS-BE/internal/storage/application/port"
"github.com/FOR-GAMERS/GAMERS-BE/internal/storage/infra"
"github.com/FOR-GAMERS/GAMERS-BE/internal/storage/presentation"
"log"
Expand All @@ -12,6 +13,7 @@ import (
type Dependencies struct {
Controller *presentation.StorageController
StorageService *application.StorageService
StoragePort port.StoragePort
}

func ProvideStorageDependencies(router *router.Router) *Dependencies {
Expand All @@ -31,5 +33,6 @@ func ProvideStorageDependencies(router *router.Router) *Dependencies {
return &Dependencies{
Controller: storageController,
StorageService: storageService,
StoragePort: storageAdapter,
}
}