Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ SELECT MAX(sc2.id)
"ORDER BY sc.measuredAt DESC")
Optional<StageCongestion> findLatestByStageId(@Param("stageId") Long stageId);

void deleteByStageIdIn(List<Long> stageIds);

}
14 changes: 14 additions & 0 deletions src/main/java/com/amp/domain/congestion/service/StageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.amp.domain.festival.entity.Festival;
import com.amp.domain.congestion.dto.request.StageRequest;
import com.amp.domain.congestion.entity.Stage;
import com.amp.domain.congestion.repository.StageCongestionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -15,8 +17,11 @@

@Service
@Transactional
@RequiredArgsConstructor
public class StageService {

private final StageCongestionRepository stageCongestionRepository;

public void syncStages(Festival festival, List<StageRequest> requests) {
if (requests == null || requests.isEmpty()) {
return;
Expand All @@ -29,6 +34,15 @@ public void syncStages(Festival festival, List<StageRequest> requests) {
Set<Long> requestIds = requests.stream()
.map(StageRequest::getId).filter(Objects::nonNull).collect(Collectors.toSet());

List<Long> removedStageIds = existStages.stream()
.map(Stage::getId)
.filter(id -> !requestIds.contains(id))
.collect(Collectors.toList());

if (!removedStageIds.isEmpty()) {
stageCongestionRepository.deleteByStageIdIn(removedStageIds);
}

existStages.removeIf(stage -> !requestIds.contains(stage.getId()));

for (StageRequest request : requests) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ public ResponseEntity<BaseResponse<FestivalDetailResponse>> getFestivalDetail(

@Operation(summary = "공연 수정")
@ApiErrorCodes(SwaggerResponseDescription.FAIL_TO_UPDATE_FESTIVAL)
@PatchMapping("/{festivalId}")
@PutMapping(value = "/{festivalId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<BaseResponse<FestivalUpdateResponse>> updateFestival(
@PathVariable Long festivalId,
@RequestBody @Valid FestivalUpdateRequest request) {
@ModelAttribute @Valid FestivalUpdateRequest request) {
FestivalUpdateResponse response = festivalService.updateFestival(festivalId, request);
return ResponseEntity
.status(SuccessStatus.FESTIVAL_UPDATE_SUCCESS.getHttpStatus())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
package com.amp.domain.festival.dto.request;

import com.amp.domain.congestion.dto.request.StageRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;
import org.springframework.web.multipart.MultipartFile;

public record FestivalUpdateRequest(
@NotBlank(message = "공연명은 필수입니다.") String title,
@NotBlank(message = "공연 장소는 필수입니다.") String location,
@Valid @NotEmpty(message = "공연 일시는 필수입니다.") List<ScheduleRequest> schedules,
@Valid @NotEmpty(message = "1개 이상의 무대/부스 정보는 필수입니다.") List<StageRequest> stages,
@NotEmpty(message = "최소 1개의 카테고리를 선택해야 합니다.") List<Long> activeCategoryIds
MultipartFile mainImage,
@NotBlank(message = "공연 일시는 필수입니다.") String schedules,
@NotBlank(message = "1개 이상의 무대/부스 정보는 필수입니다.") String stages,
@NotBlank(message = "1개 이상의 카테고리 선택은 필수입니다.") String activeCategoryIds
Comment on lines +9 to +12
Copy link
Contributor

Choose a reason for hiding this comment

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

@Valid 가 빠지면서 내부 필드 값에 대한 검증이 더이상 진행되지 않는 걸로 보이는데 상관없는건가요??

Copy link
Contributor Author

Choose a reason for hiding this comment

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

컨트롤러 파라미터에 FestivalUpdateRequest에 한 번에 붙여놨습니다. 현재 해당 클래스의 dto에는 중첩객체가 없고 단순 타입만 있어서 생략했습니다. 필요하다면 추가하겠습니다~!!

) {
}
4 changes: 4 additions & 0 deletions src/main/java/com/amp/domain/festival/entity/Festival.java
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,8 @@ public void updateDates(LocalDate startDate, LocalDate endDate) {
public void updateStartTime(LocalTime startTime) {
this.startTime = startTime;
}

public void updateMainImage(String mainImageUrl) {
this.mainImageUrl = mainImageUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import com.amp.domain.congestion.service.StageService;
import com.amp.domain.user.entity.Organizer;
import com.amp.domain.user.entity.User;
import com.amp.global.annotation.LogExecutionTime;
import com.amp.global.common.CommonErrorCode;
import com.amp.global.exception.CustomException;
import com.amp.global.s3.S3ErrorCode;
Expand All @@ -32,6 +31,8 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
Expand Down Expand Up @@ -63,41 +64,30 @@ public class FestivalService {
public FestivalCreateResponse createFestival(FestivalCreateRequest request) {
User user = authService.getCurrentUser();

if (!(user instanceof Organizer)) {
if (!(user instanceof Organizer organizer)) {
throw new CustomException(CommonErrorCode.FORBIDDEN);
}
Organizer organizer = (Organizer) user;

List<ScheduleRequest> schedules = parseJson(
request.schedules(),
new TypeReference<List<ScheduleRequest>>() {
},
FestivalErrorCode.INVALID_SCHEDULE_FORMAT
);

List<StageRequest> stages = parseJson(
request.stages(),
new TypeReference<List<StageRequest>>() {
},
FestivalErrorCode.INVALID_STAGE_FORMAT
);

List<Long> activeCategoryIds = parseJson(
request.activeCategoryIds(),
normalizeJsonArray(request.activeCategoryIds()),
new TypeReference<List<Long>>() {
},
FestivalErrorCode.INVALID_CATEGORY_FORMAT
);

if (schedules == null || schedules.isEmpty()) {
throw new CustomException(FestivalErrorCode.SCHEDULES_REQUIRED);
}
if (stages == null || stages.isEmpty()) {
throw new CustomException(FestivalErrorCode.STAGES_REQUIRED);
}
if (activeCategoryIds == null || activeCategoryIds.isEmpty()) {
throw new CustomException(CategoryErrorCode.CATEGORY_REQUIRED);
}
checkNullField(schedules, stages, activeCategoryIds);

ScheduleRequest earliestSchedule = schedules.stream()
.min(Comparator.comparing(ScheduleRequest::getFestivalDate)
Expand Down Expand Up @@ -170,11 +160,53 @@ public FestivalUpdateResponse updateFestival(Long festivalId, FestivalUpdateRequ

validateOrganizer(festival, user);

List<ScheduleRequest> schedules = parseJson(
request.schedules(),
new TypeReference<List<ScheduleRequest>>() {
},
FestivalErrorCode.INVALID_SCHEDULE_FORMAT
);
List<StageRequest> stages = parseJson(
request.stages(),
new TypeReference<List<StageRequest>>() {
},
FestivalErrorCode.INVALID_STAGE_FORMAT
);
List<Long> activeCategoryIds = parseJson(
normalizeJsonArray(request.activeCategoryIds()),
new TypeReference<List<Long>>() {
},
FestivalErrorCode.INVALID_CATEGORY_FORMAT
);

checkNullField(schedules, stages, activeCategoryIds);

festival.updateInfo(request.title(), request.location());

scheduleService.syncSchedules(festival, request.schedules());
stageService.syncStages(festival, request.stages());
categoryService.syncCategories(festival, request.activeCategoryIds());
scheduleService.syncSchedules(festival, schedules);
stageService.syncStages(festival, stages);
categoryService.syncCategories(festival, activeCategoryIds);

if (request.mainImage() != null && !request.mainImage().isEmpty()) {
String oldKey = s3Service.extractKey(festival.getMainImageUrl());
String newKey = uploadImage(request.mainImage());

festival.updateMainImage(s3Service.getPublicUrl(newKey));

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
s3Service.delete(oldKey);
}

@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
s3Service.delete(newKey);
}
}
});
}

LocalDate startDate = calculateDate(festival.getSchedules(), FestivalSchedule::getFestivalDate, true);
LocalDate endDate = calculateDate(festival.getSchedules(), FestivalSchedule::getFestivalDate, false);
Expand All @@ -197,8 +229,6 @@ public void deleteFestival(Long festivalId) {
festivalScheduleRepository.softDeleteByFestivalId(festivalId);
stageRepository.softDeleteByFestivalId(festivalId);
festivalCategoryRepository.softDeleteByFestivalId(festivalId);


festivalRepository.softDeleteById(festivalId);
}

Expand All @@ -218,7 +248,6 @@ private <T> LocalTime calculateTime(List<T> schedules, Function<T, LocalTime> ti
.orElseThrow(() -> new CustomException(FestivalErrorCode.INVALID_SCHEDULE_FORMAT));
}

@LogExecutionTime("이미지 업로드")
private String uploadImage(MultipartFile image) {
try {
return s3Service.upload(image, "festivals");
Expand All @@ -227,10 +256,17 @@ private String uploadImage(MultipartFile image) {
}
}

private String normalizeJsonArray(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.startsWith("[") ? trimmed : "[" + trimmed + "]";
}
Comment on lines +267 to +271
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Swagger의 배열 전송 방식 처리

normalizeJsonArray는 Swagger가 대괄호 없이 배열을 전송하는 경우(예: "1,2,3""[1,2,3]")를 처리합니다.

한 가지 고려사항: 빈 문자열("")이나 공백만 있는 경우(" ") "[]"로 변환되어 빈 리스트로 파싱됩니다. 현재는 checkNullField에서 빈 리스트를 걸러내므로 문제없지만, 의도를 더 명확히 하려면 빈 문자열일 때 null을 반환하는 방식도 고려할 수 있습니다:

선택적 개선안
 private String normalizeJsonArray(String value) {
     if (value == null) return null;
     String trimmed = value.trim();
+    if (trimmed.isEmpty()) return null;
     return trimmed.startsWith("[") ? trimmed : "[" + trimmed + "]";
 }

현재 구현도 정상 동작하므로 선택적 개선입니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private String normalizeJsonArray(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.startsWith("[") ? trimmed : "[" + trimmed + "]";
}
private String normalizeJsonArray(String value) {
if (value == null) return null;
String trimmed = value.trim();
if (trimmed.isEmpty()) return null;
return trimmed.startsWith("[") ? trimmed : "[" + trimmed + "]";
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/amp/domain/festival/service/organizer/FestivalService.java`
around lines 267 - 271, normalizeJsonArray currently wraps non-bracketed input
into an array string but converts empty or whitespace-only strings into "[]",
which later is filtered by checkNullField; to make intent clearer, change
normalizeJsonArray (method name) to return null when the trimmed value is empty
(i.e., treat "" or "   " as null) instead of returning "[]", so callers like
checkNullField receive null explicitly—implement by checking trimmed.isEmpty()
and returning null before the startsWith("[") check.


private <T> T parseJson(String json, TypeReference<T> typeReference, FestivalErrorCode errorCode) {
if (json == null || json.trim().isEmpty()) {
return null;
}

try {
return objectMapper.readValue(json, typeReference);
} catch (Exception e) {
Expand All @@ -243,10 +279,21 @@ private Festival findFestival(Long id) {
.orElseThrow(() -> new CustomException(FestivalErrorCode.FESTIVAL_NOT_FOUND));
}


private void validateOrganizer(Festival festival, User user) {
if (!festival.getOrganizer().getId().equals(user.getId())) {
throw new CustomException(CommonErrorCode.FORBIDDEN);
}
}

private static void checkNullField(List<ScheduleRequest> schedules, List<StageRequest> stages, List<Long> activeCategoryIds) {
if (schedules == null || schedules.isEmpty()) {
throw new CustomException(FestivalErrorCode.SCHEDULES_REQUIRED);
}
if (stages == null || stages.isEmpty()) {
throw new CustomException(FestivalErrorCode.STAGES_REQUIRED);
}
if (activeCategoryIds == null || activeCategoryIds.isEmpty()) {
throw new CustomException(CategoryErrorCode.CATEGORY_REQUIRED);
}
}
}
1 change: 1 addition & 0 deletions src/main/java/com/amp/global/s3/S3ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum S3ErrorCode implements ErrorCode {
INVALID_IMAGE_IMAGE(HttpStatus.BAD_REQUEST, "S3", "001", "이미지 파일만 업로드 가능합니다."),
FILE_NAME_NOT_FOUND(HttpStatus.BAD_REQUEST, "S3", "002", "파일명이 없습니다."),
INVALID_DIRECTORY_ROUTE(HttpStatus.BAD_REQUEST, "S3", "003", "잘못된 디렉토리 경로입니다."),
INVALID_IMAGE_URL(HttpStatus.BAD_REQUEST, "S3", "004", "유효하지 않은 이미지 URL입니다."),

// 500 Internal Server Error
S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3", "001", "S3 업로드를 실패하였습니다."),
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/com/amp/global/s3/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.amp.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
Expand Down Expand Up @@ -82,5 +81,16 @@ public String getPublicUrl(String key) {
s3Properties.getRegion()
) + key;
}
}

public String extractKey(String publicUrl) {
String baseUrl = String.format(
s3Properties.getBaseUrl(),
s3Properties.getBucket(),
s3Properties.getRegion()
);
if (!publicUrl.startsWith(baseUrl)) {
throw new CustomException(S3ErrorCode.INVALID_IMAGE_URL);
}
return publicUrl.substring(baseUrl.length());
}
}
Loading