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
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,32 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult enrollShow(Long memberId,

//posterImageUrl 필드는 이미 Converter에서 기입, 포스터 사진 DB에만 저장(1개만)
ImageRequestDTO.PosterImageRequestDTO dto = requestDTO.getPosterImageRequestDTO();

//poster 이미지는 없으면 에러
if(dto.getKeyName() == null || dto.getKeyName().isBlank()){
throw new GeneralException(ErrorStatus.INVALID_S3_KEY);
}

ImageRequestDTO.FullImageRequestDTO fullImageRequestDTO = ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(dto.getKeyName())
.filePath(FilePath.amateurShow)
.contentId(newAmateurShow.getId())
.memberId(memberId)
.build();
imageService.saveImage(memberId, fullImageRequestDTO);

// // 좋아요한 멤버리스트
// List<MemberLike> memberLikers = memberLikeRepository.findByPerformerId(memberId);
// // 좋아요한 멤버가 한 명 이상일 때만
// if(!memberLikers.isEmpty()) {
// List<Member> likers = memberLikers.stream()
// .map(MemberLike::getLiker)
// .collect(Collectors.toList());
//
// eventPublisher.publishEvent(new NewShowEvent(newAmateurShow.getId(), memberId, likers)); //공연등록 이벤트 생성
// }

imageService.saveImageWithImageUrl(memberId, fullImageRequestDTO, Optional.ofNullable(dto.getImageUrl()));


// 좋아요한 멤버리스트
List<MemberLike> memberLikers = memberLikeRepository.findByPerformerId(memberId);
// 좋아요한 멤버가 한 명 이상일 때만
if(!memberLikers.isEmpty()) {
List<Member> likers = memberLikers.stream()
.map(MemberLike::getLiker)
.collect(Collectors.toList());

eventPublisher.publishEvent(new NewShowEvent(newAmateurShow.getId(), memberId, likers)); //공연등록 이벤트 생성
}

// response
return AmateurConverter.toAmateurEnrollDTO(newAmateurShow);
Expand All @@ -109,13 +117,24 @@ private void saveRelatedEntity(AmateurEnrollRequestDTO requestDTO, AmateurShow a
List<AmateurCasting> amateurCastings = amateurCastingRepository.saveAll(castings);
// 캐스팅 사진 저장(1개씩)
amateurCastings.forEach(amateurCasting -> {
String keyName = amateurCasting.getCastingImageKeyName();
// keyName 없으면 스킵
if (keyName == null || keyName.isBlank()) {
return;
}

ImageRequestDTO.FullImageRequestDTO fullImageRequestDTO = ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(amateurCasting.getCastingImageKeyName())
.filePath(FilePath.amateurShow)
.contentId(amateurShow.getId())
.filePath(FilePath.casting)
.contentId(amateurCasting.getId())
.memberId(memberId)
.build();
imageService.saveImage(memberId, fullImageRequestDTO);

imageService.saveImageWithImageUrl(
memberId,
fullImageRequestDTO,
Optional.ofNullable(amateurCasting.getCastingImageUrl())
);
});
}

Expand All @@ -124,14 +143,22 @@ private void saveRelatedEntity(AmateurEnrollRequestDTO requestDTO, AmateurShow a
if (amateurNotice != null) {
amateurNoticeRepository.save(amateurNotice);

ImageRequestDTO.FullImageRequestDTO fullImageRequestDTO = ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(requestDTO.getNotice().getNoticeImageRequestDTO().getKeyName())
.filePath(FilePath.amateurShow)
.contentId(amateurShow.getId())
.memberId(memberId)
.build();
ImageRequestDTO.NoticeImageRequestDTO noticeImageDTO = requestDTO.getNotice().getNoticeImageRequestDTO();
//keyName 비었으면 스킵
if (noticeImageDTO != null && noticeImageDTO.getKeyName() != null && !noticeImageDTO.getKeyName().isBlank()) {
ImageRequestDTO.FullImageRequestDTO fullImageRequestDTO = ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(noticeImageDTO.getKeyName())
.filePath(FilePath.notice)
.contentId(amateurNotice.getId())
.memberId(memberId)
.build();

imageService.saveImage(memberId, fullImageRequestDTO);
imageService.saveImageWithImageUrl(
memberId,
fullImageRequestDTO,
Optional.ofNullable(noticeImageDTO.getImageUrl())
);
}
}

// 티켓
Expand Down Expand Up @@ -170,32 +197,17 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult updateShow(Long memberId, Lo
throw new GeneralException(ErrorStatus.MEMBER_NOT_AUTHORIZED);
}

//포스터 사진 수정
ImageRequestDTO.PosterImageRequestDTO dto = requestDTO.getPosterImageRequestDTO();
if (dto != null && dto.getKeyName() != null && !dto.getKeyName().isBlank()) {
// 현재 포스터 이미지 조회 (show당 1개)
Image existingImage = imageRepository
.findAllByFilePathAndContentId(FilePath.amateurShow, amateurShow.getId())
.stream()
.findFirst()
.orElse(null);

// 기존 keyName과 다르면 기존 이미지 삭제 후 교체
if (existingImage != null && !existingImage.getKeyName().equals(dto.getKeyName())) {
imageService.deleteImage(existingImage.getId(), memberId);
}
imageService.updateShowImage(
memberId,
dto.getKeyName(),
Optional.ofNullable(dto.getImageUrl()),
amateurShow.getId(),
FilePath.amateurShow
);

ImageRequestDTO.FullImageRequestDTO fullImageRequestDTO = ImageRequestDTO.FullImageRequestDTO.builder()
.keyName(dto.getKeyName())
.filePath(FilePath.amateurShow)
.contentId(amateurShow.getId())
.memberId(memberId)
.build();

imageService.saveImage(memberId, fullImageRequestDTO);
//amateurShow 엔티티 내 posterImageUrl 필드 수정
amateurShow.updatePosterImageUrl(dto.getImageUrl());

}
Comment on lines 201 to 211
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Poster update via updateShowImage is cleaner than delete+insert; watch for null imageUrl.
If FE ever sends keyName but omits imageUrl, you’ll set amateurShow.posterImageUrl to null (Line 210-211). If poster URL is required for reads, consider requiring imageUrl when keyName is provided (or derive URL server-side).

🤖 Prompt for AI Agents
In
src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java
around lines 201-211, the code updates amateurShow.posterImageUrl with
dto.getImageUrl() even when dto.getKeyName() is present but imageUrl is null,
which can wipe out the stored URL; either validate and require dto.getImageUrl()
when dto.getKeyName() is provided (return a 4xx error), or else derive/resolve
the image URL server-side before updating and only call
amateurShow.updatePosterImageUrl(...) when a non-null imageUrl is available;
implement one of these fixes and add a unit-test or input validation to prevent
null poster URL updates.


// 기본 정보 업데이트
Expand Down Expand Up @@ -234,6 +246,21 @@ private void updateNotice(AmateurShow amateurShow, AmateurUpdateRequestDTO.Updat
}
}
}

//NoticeImage 수정
if (noticeDTO == null) return;

AmateurNotice notice = amateurShow.getAmateurNotice();
ImageRequestDTO.NoticeImageRequestDTO dto = noticeDTO.getNoticeImageRequestDTO();
if (notice != null && dto != null && dto.getKeyName() != null && !dto.getKeyName().isBlank()){
imageService.updateShowImage(
amateurShow.getMember().getId(),
dto.getKeyName(),
Optional.ofNullable(dto.getImageUrl()),
notice.getId(),
FilePath.notice
);
}
}

private void updateCasting(AmateurShow show, List<AmateurUpdateRequestDTO.UpdateCasting> dtos) {
Expand All @@ -246,16 +273,35 @@ private void updateCasting(AmateurShow show, List<AmateurUpdateRequestDTO.Update
List<AmateurCasting> updatedList = new ArrayList<>();

for (AmateurUpdateRequestDTO.UpdateCasting dto : dtos) {
Long contentId;

if (dto.getCastingId() != null && existingMap.containsKey(dto.getCastingId())) {
// 기존 객체 수정
AmateurCasting existing = existingMap.get(dto.getCastingId());
existing.update(dto);
updatedList.add(existing);
existingMap.remove(dto.getCastingId());
contentId = existing.getId();
} else {
// 새 객체 추가
AmateurCasting newCasting = AmateurConverter.toSingleCasting(dto, show);
updatedList.add(newCasting);
AmateurCasting savedCasting = amateurCastingRepository.save(newCasting);
updatedList.add(savedCasting);

contentId = savedCasting.getId();
}


// 캐스팅 이미지 업데이트
ImageRequestDTO.CastingImageRequestDTO castingDTO = dto.getCastingImageRequestDTO();
if (castingDTO != null && castingDTO.getKeyName() != null && !castingDTO.getKeyName().isBlank()) {
imageService.updateShowImage(
show.getMember().getId(),
castingDTO.getKeyName(),
Optional.ofNullable(castingDTO.getImageUrl()),
contentId,
FilePath.casting
);
}
}

Expand All @@ -267,6 +313,7 @@ private void updateCasting(AmateurShow show, List<AmateurUpdateRequestDTO.Update
// 최종 리스트 갱신
show.getAmateurCastingList().clear();
show.getAmateurCastingList().addAll(updatedList);

}

private void updateStaff(AmateurShow show, List<AmateurUpdateRequestDTO.UpdateStaff> dtos) {
Expand Down Expand Up @@ -363,10 +410,28 @@ public void deleteShow(Long memberId, Long amateurShowId) {
throw new GeneralException(ErrorStatus.MEMBER_NOT_AUTHORIZED);
}

amateurShowRepository.delete(amateurShow);
//포스터 삭제
Image posterImg = imageRepository.findByFilePathAndContentId(FilePath.amateurShow, amateurShowId);
imageService.deleteImage(posterImg.getId(), memberId);
Comment on lines +413 to +415
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing null check for poster image before deletion.

findByFilePathAndContentId may return null if no poster image exists, causing an NPE on Line 407. Add a null check for consistency with the notice image handling below.

 //포스터 삭제
 Image posterImg = imageRepository.findByFilePathAndContentId(FilePath.amateurShow, amateurShowId);
-imageService.deleteImage(posterImg.getId(), memberId);
+if (posterImg != null) {
+    imageService.deleteImage(posterImg.getId(), memberId);
+}
🤖 Prompt for AI Agents
In
src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java
around lines 405 to 407, the code calls
imageRepository.findByFilePathAndContentId(...) and immediately uses
posterImg.getId(), which can NPE if no poster exists; add a null check like the
one used for the notice image: only call imageService.deleteImage(...) when
posterImg is not null (and handle/log accordingly) to avoid
NullPointerException.


// 공지 삭제
if (amateurShow.getAmateurNotice() != null) {
Image noticeImg = imageRepository.findByFilePathAndContentId(FilePath.notice, amateurShow.getAmateurNotice().getId());
if (noticeImg != null) {
imageService.deleteImage(noticeImg.getId(), memberId);
}
}

List<Image> images = imageRepository.findAllByFilePathAndContentId(FilePath.amateurShow, amateurShowId);
images.forEach(image -> imageService.deleteImage(image.getId(), memberId));
//캐스팅 삭제
List<AmateurCasting> amateurCastings = amateurShow.getAmateurCastingList();
List<Image> castingImages = amateurCastings.stream()
.map(casting -> imageRepository.findByFilePathAndContentId(FilePath.casting, casting.getId()))
.filter(Objects::nonNull)
.toList();
castingImages.forEach(image -> imageService.deleteImage(image.getId(), memberId));

//amateurShow 삭제
amateurShowRepository.delete(amateurShow);
}

// 소극장 공연 단건 조회
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/cc/backend/config/s3/S3Controller.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public class S3Controller {
@GetMapping("/uploadUrl")
public ResponseEntity<Map<String, String>> getPresignedPutUrl(
@Parameter(description = "업로드할 이미지 파일의 확장자(jpg, jpeg, png, gif)", required = true) @RequestParam @NotBlank String imageExtension,
@Parameter(description = "업로드할 기능(board, photoAlbum, amateurShow)", required = true) @RequestParam FilePath filePath,
@Parameter(description = "업로드할 사진 종류(board, photoAlbum, amateurShow(포스터), notice, casting)", required = true) @RequestParam FilePath filePath,
@AuthenticationPrincipal(expression = "member") Member member) {

return ResponseEntity.ok(s3Service.createPresignedPutUrl(imageExtension, filePath, member.getId()));
Expand All @@ -46,7 +46,7 @@ public ResponseEntity<Map<String, String>> getPresignedPutUrl(
public ResponseEntity<List<Map<String, String>>> getPresignedPutUrls (
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "각 이미지의 확장자 jpg, jpeg, png, gif ...", required = true) @RequestBody List<@NotBlank String> extensions,
@Parameter(description = "업로드할 기능(board, photoAlbum, amateurShow)", required = true) @RequestParam FilePath filePath,
@Parameter(description = "업로드할 기능(board, photoAlbum)", required = true) @RequestParam FilePath filePath,
@AuthenticationPrincipal(expression = "member") Member member) {

return ResponseEntity.ok(s3Service.createPresignedPutUrls(extensions, filePath, member.getId()));
Expand Down Expand Up @@ -83,6 +83,9 @@ public void deleteFile(@RequestParam String keyName,
@Operation(summary = "keyName에 해당하는 파일이 S3에 실제 존재하는지 확인")
public ResponseEntity<Boolean> doesObjectExist(@RequestParam String keyName,
@AuthenticationPrincipal(expression = "member") Member member){
if(keyName == null){
return ResponseEntity.ok(false);
}
return ResponseEntity.ok(s3Service.doesObjectExist(keyName, member.getId()));
Comment on lines +86 to 89
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

/s3/exist should likely treat blank keyName the same as null
Returning false for null but throwing for "" / " " is surprising for an “exist” endpoint.

-        if(keyName == null){
+        if (keyName == null || keyName.isBlank()) {
             return ResponseEntity.ok(false);
         }
📝 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
if(keyName == null){
return ResponseEntity.ok(false);
}
return ResponseEntity.ok(s3Service.doesObjectExist(keyName, member.getId()));
if (keyName == null || keyName.isBlank()) {
return ResponseEntity.ok(false);
}
return ResponseEntity.ok(s3Service.doesObjectExist(keyName, member.getId()));
🤖 Prompt for AI Agents
In src/main/java/cc/backend/config/s3/S3Controller.java around lines 86 to 89,
the endpoint returns false when keyName is null but will throw for empty or
whitespace-only strings; normalize the input by treating blank values the same
as null (e.g., check keyName == null || keyName.trim().isEmpty() or use
StringUtils.isBlank(keyName)) and return ResponseEntity.ok(false) for those
cases so empty/whitespace keys do not cause errors and behavior is consistent.

}
}
14 changes: 7 additions & 7 deletions src/main/java/cc/backend/config/s3/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ public Map<String, String> createPresignedPutUrl(String imageExtension, FilePath
throw new GeneralException(ErrorStatus.INVALID_FILE_EXTENSION);
}

// MIME 타입 처리 (jpg는 image/jpeg)
String mimeType = switch (ext) {
case "jpg", "jpeg" -> "image/jpeg";
case "png" -> "image/png";
case "gif" -> "image/gif";
default -> throw new GeneralException(ErrorStatus.INVALID_FILE_EXTENSION);
};
// // MIME 타입 처리 (jpg는 image/jpeg)
// String mimeType = switch (ext) {
// case "jpg", "jpeg" -> "image/jpeg";
// case "png" -> "image/png";
// case "gif" -> "image/gif";
// default -> throw new GeneralException(ErrorStatus.INVALID_FILE_EXTENSION);
// };

PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/cc/backend/image/FilePath.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package cc.backend.image;

public enum FilePath {
board, photoAlbum, amateurShow
board, photoAlbum, amateurShow, notice, casting
}
55 changes: 55 additions & 0 deletions src/main/java/cc/backend/image/service/ImageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.List;
import java.util.Map;

import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@Service
Expand All @@ -40,18 +41,29 @@ public class ImageService {
@Transactional
public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImage(Long memberId, ImageRequestDTO.FullImageRequestDTO requestDTO) {

return saveImageWithImageUrl(memberId, requestDTO, Optional.empty());
}

@Transactional
public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImageWithImageUrl(
Long memberId,
ImageRequestDTO.FullImageRequestDTO requestDTO,
Optional<String> imageUrlOpt) {

memberRepository.findById(memberId).orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND));
//S3에 실제 존재하는 이미지인지 검증
if(!s3Service.doesObjectExist(requestDTO.getKeyName(), memberId)) {
throw new GeneralException(ErrorStatus.NOT_FOUND_IN_S3);
}

String imageUrl = imageUrlOpt.orElse(null);
Image image = Image.builder()
.keyName(requestDTO.getKeyName())
.filePath(requestDTO.getFilePath())
.contentId(requestDTO.getContentId())
.uploadedAt(LocalDateTime.now())
.memberId(memberId)
.imageUrl(imageUrl)
.build();

Image newImage = imageRepository.save(image);
Expand Down Expand Up @@ -161,4 +173,47 @@ public void deleteImage(Long imageId, Long memberId) {
}
}

//amateurShow(poster, notice, casting) 이미지 수정
@Transactional
public void updateShowImage(
Long memberId,
String keyName,
Optional<String> imageUrlOpt,
Long contentId,
FilePath filePath) {
if (keyName == null || keyName.isBlank()) {
return; // keyName 없으면 처리하지 않음
}

if (!s3Service.doesObjectExist(keyName, memberId)) {
throw new GeneralException(ErrorStatus.NOT_FOUND_IN_S3);
}
String imageUrl = imageUrlOpt.orElse(null);

// 기존 이미지 조회 (filePath + contentId 기준)
Image existingImage = imageRepository
.findByFilePathAndContentId(filePath, contentId);

// 기존 이미지가 있고, keyName이 다르면 삭제 후 새로 저장
if (existingImage != null) {
// keyName 변경이면 S3/DB 모두 삭제
if (!existingImage.getKeyName().equals(keyName)) {
deleteImage(existingImage.getId(), memberId);}
else {// 같은 keyName이면 DB row만 교체(중복 방지), S3는 유지
imageRepository.delete(existingImage);
}
}
Comment on lines +193 to +205
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find ImageRepository interface definition
echo "=== Searching for ImageRepository interface ==="
rg -n "interface ImageRepository|findByFilePathAndContentId" --type=java -A 5

# 2) Find Image entity class
echo "=== Searching for Image entity class ==="
rg -n "class Image\b" --type=java -B 3 -A 30 | head -100

# 3) Search for unique constraints or indexes in entity or annotations
echo "=== Searching for unique constraints/indexes ==="
rg -n "@UniqueConstraint|@Index|uniqueConstraints|@Column.*unique" --type=java -B 2 -A 2

Repository: SeeATheater/CC_Backend

Length of output: 8773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for migration files or SQL schema definitions
echo "=== Searching for migrations or schema files ==="
fd -e 'sql' -e 'yml' -e 'yaml' | head -20

echo ""
echo "=== Searching for Flyway/Liquibase migrations ==="
find . -type f \( -name "V*.sql" -o -name "*.sql" \) 2>/dev/null | head -20

echo ""
echo "=== Checking for unique constraint in schema files ==="
rg -n "UNIQUE|unique|constraint" -g '*.sql' -C 2 | grep -i "file_path\|content_id\|image" -C 2 || echo "No matches found"

Repository: SeeATheater/CC_Backend

Length of output: 565


Add unique constraint on (filePath, contentId) to prevent duplicate image rows.

findByFilePathAndContentId() at line 195 assumes a single row exists, but the database does not enforce uniqueness on this combination. The @Index(name = "idx_file_content", columnList = "file_path, content_id") on the Image entity is for query performance only and allows duplicates. Additionally, the repository defines both findByFilePathAndContentId() (singular) and findAllByFilePathAndContentId() (plural), indicating duplicates are possible. If duplicates occur, only the first result is returned with undefined ordering, risking data inconsistency.

Add uniqueConstraints = @UniqueConstraint(columnNames = {"file_path", "content_id"}) to the @Table annotation on the Image entity to enforce this constraint at the database level.

🤖 Prompt for AI Agents
In src/main/java/cc/backend/image/service/ImageService.java around lines 193 to
205, the code assumes (filePath, contentId) uniqueness but the DB lacks
enforcement; update the Image entity's @Table annotation (where Image is
defined) to include uniqueConstraints = @UniqueConstraint(columnNames =
{"file_path","content_id"}) so the database enforces uniqueness, create and run
a migration to add that unique constraint (cleaning or deduping existing rows
first as part of the migration), and ensure repository/logic continues to use
singular findByFilePathAndContentId() once the constraint is added.

// 새 이미지 저장
Image image = Image.builder()
.keyName(keyName)
.imageUrl(imageUrl)
.filePath(filePath)
.contentId(contentId)
.uploadedAt(LocalDateTime.now())
.memberId(memberId)
.build();

imageRepository.save(image);
}

}