From d36bd658be61323a3221f2b55a684310a293a325 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Sun, 9 Nov 2025 02:59:13 +0900 Subject: [PATCH 01/69] =?UTF-8?q?feat:=20Url=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 + .../moadong/global/exception/ErrorCode.java | 13 +- .../media/controller/ClubImageController.java | 80 +++-- .../media/dto/PresignedUploadResponse.java | 11 + .../media/dto/UploadCompleteRequest.java | 5 + .../moadong/media/dto/UploadUrlRequest.java | 5 + .../media/service/CloudflareImageService.java | 298 +++++++++++++----- .../media/service/ClubImageService.java | 21 +- .../java/moadong/media/util/S3Config.java | 22 ++ 9 files changed, 350 insertions(+), 106 deletions(-) create mode 100644 backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java create mode 100644 backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java create mode 100644 backend/src/main/java/moadong/media/dto/UploadUrlRequest.java diff --git a/backend/build.gradle b/backend/build.gradle index f0927d29f..09af47009 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -53,6 +53,7 @@ dependencies { // S3 implementation 'software.amazon.awssdk:s3:2.26.0' implementation 'software.amazon.awssdk:auth:2.26.0' + implementation 'software.amazon.awssdk:url-connection-client:2.26.0' // resize tool implementation 'net.coobird:thumbnailator:0.4.14' diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index 7f9303175..c6deff8ca 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -6,6 +6,8 @@ @Getter public enum ErrorCode { CONCURRENCY_CONFLICT(HttpStatus.CONFLICT, "100-1","다른 사용자가 먼저 수정했습니다. 페이지를 새로고침 후 다시 이용해주세요"), + + // 600xx: Club 관련 오류 CLUB_NOT_FOUND(HttpStatus.NOT_FOUND, "600-1", "동아리가 존재하지 않습니다."), CLUB_INFORMATION_NOT_FOUND(HttpStatus.NOT_FOUND, "600-2", "동아리 상세 정보가 존재하지 않습니다."), CLUB_FEED_IMAGES_NOT_FOUND(HttpStatus.NOT_FOUND, "600-3", "동아리 피드가 존재하지 않습니다."), @@ -17,6 +19,7 @@ public enum ErrorCode { TOO_LONG_TAG(HttpStatus.BAD_REQUEST, "600-9", "태그는 최대 5글자까지 입력할 수 있습니다."), TOO_LONG_INTRODUCTION(HttpStatus.BAD_REQUEST, "600-10", "소개는 최대 24글자까지 입력할 수 있습니다."), + // 601xx: 파일/미디어 관련 오류 IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-1", "이미지 업로드에 실패하였습니다."), FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "601-2", "이미지 파일을 찾을 수 없습니다."), TOO_MANY_FILES(HttpStatus.PAYLOAD_TOO_LARGE, "601-3", "이미지 파일이 최대치보다 많습니다."), @@ -24,19 +27,26 @@ public enum ErrorCode { KOREAN_FILE_NAME(HttpStatus.INTERNAL_SERVER_ERROR, "601-5", "파일명의 한국어를 인코딩할 수 없습니다."), FILE_TRANSFER_ERROR(HttpStatus.BAD_REQUEST, "601-6", "파일을 올바른 형식으로 변경할 수 없습니다."), UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "601-7", "파일의 확장자가 올바르지 않습니다."), + INVALID_FILE_URL(HttpStatus.BAD_REQUEST, "601-8", "올바르지 않은 파일 URL입니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-9", "파일 삭제에 실패하였습니다."), + FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "601-10", "파일 용량이 제한을 초과했습니다."), + // 700xx: 사용자/권한 관련 오류 USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "700-1", "이미 존재하는 계정입니다."), USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "700-2", "존재하지 않는 계정입니다."), USER_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "700-3", "올바르지 않은 유저 형식입니다."), USER_INVALID_LOGIN(HttpStatus.BAD_REQUEST, "700-4", "올바르지 않은 로그인"), USER_UNAUTHORIZED(HttpStatus.FORBIDDEN, "700-5", "권한이 없습니다."), + // 701xx: 토큰 관련 오류 TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "701-1", "유효하지 않은 토큰입니다."), TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "701-2", "토큰이 만료되었습니다."), + // 702xx: 비밀번호 정책 PASSWORD_SAME_AS_USERID(HttpStatus.BAD_REQUEST, "702-1", "아이디와 동일한 비밀번호는 설정할 수 없습니다."), PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST,"702-2","이전 비밀번호와 동일한 비밀번호는 설정할 수 없습니다."), + // 800xx: 지원서/문항 관련 APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-1", "지원서 양식이 존재하지 않습니다."), SHORT_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-2", "단답형 최대 글자를 초과하였습니다."), LONG_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-3", "장문형 최대 글자를 초과하였습니다."), @@ -44,12 +54,13 @@ public enum ErrorCode { REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."), ACTIVE_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-6", "활성화된 지원서 양식이 존재하지 않습니다."), + // 900xx: 기타 시스템 오류 AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."), APPLICANT_NOT_FOUND(HttpStatus.NOT_FOUND, "900-2", "지원서가 존재하지 않습니다."), + // 901xx: FCM 관련 오류 FCMTOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "901-1", "존재하지 않는 토큰입니다."), FCMTOKEN_SUBSCRIBE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "901-2", "동아리 구독중에 오류가 발생 하였습니다."); - ; private final HttpStatus httpStatus; private final String code; diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index d8879e7b8..f725ae910 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import moadong.global.payload.Response; import moadong.media.dto.FeedUpdateRequest; +import moadong.media.dto.PresignedUploadResponse; +import moadong.media.dto.UploadCompleteRequest; +import moadong.media.dto.UploadUrlRequest; import moadong.media.service.ClubImageService; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; @@ -15,9 +17,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/club") @@ -32,14 +32,6 @@ public ClubImageController(@Qualifier("cloudflare") ClubImageService clubImageSe private final ClubImageService clubImageService; - @PostMapping(value = "/{clubId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "로고 이미지 업데이트", description = "cloudflare 상에 로고 이미지를 업데이트합니다.") - public ResponseEntity uploadLogo(@PathVariable String clubId, - @RequestPart("logo") MultipartFile file) { - String fileUrl = clubImageService.uploadLogo(clubId, file); - return Response.ok(fileUrl); - } - @DeleteMapping(value = "/{clubId}/logo") @Operation(summary = "로고 이미지 삭제", description = "cloudflare 상에 로고 이미지를 저장소에서 삭제합니다.") public ResponseEntity deleteLogo(@PathVariable String clubId) { @@ -47,12 +39,6 @@ public ResponseEntity deleteLogo(@PathVariable String clubId) { return Response.ok("success delete logo"); } - @PostMapping(value = "/{clubId}/feed", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "피드 이미지 업로드", description = "cloudflare 상에 피드에 사용할 이미지를 업로드하고 주소를 반환받습니다.") - public ResponseEntity uploadFeed(@PathVariable String clubId, @RequestPart("feed") MultipartFile file) { - return Response.ok(clubImageService.uploadFeed(clubId, file)); - } - @PostMapping(value = "/{clubId}/feeds") @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "cloudflare 상에 피드 이미지의 설정을 업데이트 합니다.") public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody FeedUpdateRequest feeds) { @@ -60,18 +46,62 @@ public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody Feed return Response.ok("success put feeds"); } - @PostMapping(value = "/{clubId}/cover", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "커버 이미지 업데이트", description = "cloudflare 상에 커버 이미지를 업데이트합니다.") - public ResponseEntity uploadCover(@PathVariable String clubId, - @RequestPart("cover") MultipartFile file) { - String fileUrl = clubImageService.uploadCover(clubId, file); - return Response.ok(fileUrl); - } - @DeleteMapping(value = "/{clubId}/cover") @Operation(summary = "커버 이미지 삭제", description = "cloudflare 상에 커버 이미지를 저장소에서 삭제합니다.") public ResponseEntity deleteCover(@PathVariable String clubId) { clubImageService.deleteCover(clubId); return Response.ok("success delete cover"); } + + // Presigned URL 방식 엔드포인트 + @PostMapping("/{clubId}/logo/upload-url") + @Operation(summary = "로고 이미지 업로드 URL 생성", description = "로고 이미지 업로드를 위한 Presigned URL을 생성합니다.") + public ResponseEntity generateLogoUploadUrl(@PathVariable String clubId, + @RequestBody UploadUrlRequest request) { + PresignedUploadResponse response = clubImageService.generateLogoUploadUrl( + clubId, request.fileName(), request.contentType()); + return Response.ok(response); + } + + @PostMapping("/{clubId}/logo/complete") + @Operation(summary = "로고 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") + public ResponseEntity completeLogoUpload(@PathVariable String clubId, + @RequestBody UploadCompleteRequest request) { + clubImageService.completeLogoUpload(clubId, request.fileUrl()); + return Response.ok("success upload logo"); + } + + @PostMapping("/{clubId}/feed/upload-url") + @Operation(summary = "피드 이미지 업로드 URL 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 생성합니다.") + public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, + @RequestBody UploadUrlRequest request) { + PresignedUploadResponse response = clubImageService.generateFeedUploadUrl( + clubId, request.fileName(), request.contentType()); + return Response.ok(response); + } + + @PostMapping("/{clubId}/feed/complete") + @Operation(summary = "피드 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") + public ResponseEntity completeFeedUpload(@PathVariable String clubId, + @RequestBody UploadCompleteRequest request) { + clubImageService.completeFeedUpload(clubId, request.fileUrl()); + return Response.ok("success upload feed"); + } + + @PostMapping("/{clubId}/cover/upload-url") + @Operation(summary = "커버 이미지 업로드 URL 생성", description = "커버 이미지 업로드를 위한 Presigned URL을 생성합니다.") + public ResponseEntity generateCoverUploadUrl(@PathVariable String clubId, + @RequestBody UploadUrlRequest request) { + PresignedUploadResponse response = clubImageService.generateCoverUploadUrl( + clubId, request.fileName(), request.contentType()); + return Response.ok(response); + } + + @PostMapping("/{clubId}/cover/complete") + @Operation(summary = "커버 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") + public ResponseEntity completeCoverUpload(@PathVariable String clubId, + @RequestBody UploadCompleteRequest request) { + clubImageService.completeCoverUpload(clubId, request.fileUrl()); + return Response.ok("success upload cover"); + } } diff --git a/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java new file mode 100644 index 000000000..d43a6e168 --- /dev/null +++ b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java @@ -0,0 +1,11 @@ +package moadong.media.dto; + +import java.util.Map; + +public record PresignedUploadResponse( + String presignedUrl, + String finalUrl, + Map requiredHeaders +) { +} + diff --git a/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java new file mode 100644 index 000000000..58734e68e --- /dev/null +++ b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java @@ -0,0 +1,5 @@ +package moadong.media.dto; + +public record UploadCompleteRequest(String fileUrl) { +} + diff --git a/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java new file mode 100644 index 000000000..d8286b881 --- /dev/null +++ b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java @@ -0,0 +1,5 @@ +package moadong.media.dto; + +public record UploadUrlRequest(String fileName, String contentType) { +} + diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index b50592969..0198fa908 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -2,11 +2,15 @@ import static moadong.media.util.ClubImageUtil.containsInvalidChars; import static moadong.media.util.ClubImageUtil.isImageExtension; -import static moadong.media.util.ClubImageUtil.resizeImage; -import java.io.IOException; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import moadong.club.entity.Club; import moadong.club.repository.ClubRepository; import moadong.global.exception.ErrorCode; @@ -14,16 +18,22 @@ import moadong.global.util.ObjectIdConverter; import moadong.global.util.RandomStringUtil; import moadong.media.domain.FileType; +import moadong.media.dto.PresignedUploadResponse; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +@Slf4j @Service("cloudflare") @RequiredArgsConstructor public class CloudflareImageService implements ClubImageService{ @@ -32,6 +42,8 @@ public class CloudflareImageService implements ClubImageService{ private final S3Client s3Client; + private final S3Presigner s3Presigner; + @Value("${server.feed.max-count}") private int MAX_FEED_COUNT; @Value("${cloud.aws.s3.bucket}") @@ -39,25 +51,22 @@ public class CloudflareImageService implements ClubImageService{ @Value("${cloud.aws.s3.view-endpoint}") private String viewEndpoint; @Value("${server.image.max-size}") - private long MAX_SIZE; - - @Override - public String uploadLogo(String clubId, MultipartFile file) { - Club club = getClub(clubId); - - if (club.getClubRecruitmentInformation().getLogo() != null) { - deleteFile(club, club.getClubRecruitmentInformation().getLogo()); - } - - String filePath = uploadFile(clubId, file, FileType.LOGO); - club.updateLogo(filePath); - clubRepository.save(club); - return filePath; + private long maxImageSizeBytes; + @Value("${server.file-url.max-length}") + private int maxFileUrlLength; + private String normalizedViewEndpoint; + + @PostConstruct + private void init() { + // viewEndpoint 정규화: 후행 슬래시 제거 + normalizedViewEndpoint = viewEndpoint != null ? viewEndpoint.replaceAll("/+$", "") : ""; } @Override + @Transactional public void deleteLogo(String clubId) { Club club = getClub(clubId); + validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getLogo() != null) { deleteFile(club, club.getClubRecruitmentInformation().getLogo()); @@ -67,22 +76,28 @@ public void deleteLogo(String clubId) { } @Override - public String uploadFeed(String clubId, MultipartFile file) { - int feedImagesCount = getClub(clubId).getClubRecruitmentInformation().getFeedImages().size(); - - if (feedImagesCount + 1 > MAX_FEED_COUNT) { - throw new RestApiException(ErrorCode.TOO_MANY_FILES); - } - return uploadFile(clubId, file, FileType.FEED); - } - - @Override + @Transactional public void updateFeeds(String clubId, List newFeedImageList) { Club club = getClub(clubId); + validateClubRecruitmentInformation(club); if (newFeedImageList.size() > MAX_FEED_COUNT) { throw new RestApiException(ErrorCode.TOO_MANY_FILES); } + // 각 URL에 대한 제약 검증 수행(기존 URL은 스킵) + List existing = club.getClubRecruitmentInformation().getFeedImages(); + if (existing == null || existing.isEmpty()) { + for (String url : newFeedImageList) { + validateFileConstraints(url); + } + } else { + java.util.HashSet existingSet = new java.util.HashSet<>(existing); + for (String url : newFeedImageList) { + if (!existingSet.contains(url)) { + validateFileConstraints(url); + } + } + } List feedImages = club.getClubRecruitmentInformation().getFeedImages(); if (feedImages != null && !feedImages.isEmpty()) { @@ -109,89 +124,226 @@ private void deleteFeedImages(Club club, List feedImages, List n @Override public void deleteFile(Club club, String filePath) { - // https://pub-8655aea549d544239ad12d0385aa98aa.r2.dev/{key} -> {key} - String key = filePath.substring(viewEndpoint.length()+1); + if (filePath == null || filePath.isEmpty()) { + log.warn("deleteFile called with null or empty filePath for club: {}", club.getId()); + return; + } + + String key = extractKeyOrNull(filePath); + if (key == null) { + log.warn("Invalid filePath format for club {}: expected prefix {}", club.getId(), normalizedViewEndpoint + "/"); + return; + } DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() .bucket(bucketName) .key(key) .build(); - s3Client.deleteObject(deleteRequest); + try { + s3Client.deleteObject(deleteRequest); + } catch (S3Exception e) { + // 파일이 이미 없거나 삭제 권한이 없는 경우 로그만 남기고 계속 진행 + log.warn("Failed to delete file from S3 for club {}: key={}, error={}", club.getId(), key, e.getMessage()); + } catch (Exception e) { + log.warn("Unexpected error while deleting file from S3 for club {}: key={}, error={}", club.getId(), key, e.getMessage()); + } } @Override - public String uploadCover(String clubId, MultipartFile file) { + @Transactional + public void deleteCover(String clubId) { Club club = getClub(clubId); + validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getCover() != null) { deleteFile(club, club.getClubRecruitmentInformation().getCover()); } + club.updateCover(null); + clubRepository.save(club); + } + + @Override + public PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileName, String contentType) { + validateFileName(fileName); + return generatePresignedUrl(clubId, fileName, contentType, FileType.LOGO); + } + + @Override + public PresignedUploadResponse generateFeedUploadUrl(String clubId, String fileName, String contentType) { + validateFileName(fileName); + Club club = getClub(clubId); + validateClubRecruitmentInformation(club); + + List feedImages = club.getClubRecruitmentInformation().getFeedImages(); + int feedImagesCount = (feedImages == null) ? 0 : feedImages.size(); + if (feedImagesCount + 1 > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + return generatePresignedUrl(clubId, fileName, contentType, FileType.FEED); + } - String filePath = uploadFile(clubId, file, FileType.COVER); - club.updateCover(filePath); + @Override + public PresignedUploadResponse generateCoverUploadUrl(String clubId, String fileName, String contentType) { + validateFileName(fileName); + return generatePresignedUrl(clubId, fileName, contentType, FileType.COVER); + } + + @Override + @Transactional + public void completeLogoUpload(String clubId, String fileUrl) { + validateFileConstraints(fileUrl); + Club club = getClub(clubId); + validateClubRecruitmentInformation(club); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + + club.updateLogo(fileUrl); clubRepository.save(club); - return filePath; } @Override - public void deleteCover(String clubId) { + @Transactional + public void completeFeedUpload(String clubId, String fileUrl) { + validateFileConstraints(fileUrl); + + // 트랜잭션 내에서 최신 데이터를 다시 조회하여 Race Condition 방지 + Club club = getClub(clubId); + validateClubRecruitmentInformation(club); + + List feedImages = club.getClubRecruitmentInformation().getFeedImages(); + if (feedImages == null) { + feedImages = new ArrayList<>(); + } + + // 동시 요청 시 최신 상태를 다시 확인 + if (feedImages.size() + 1 > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + + feedImages.add(fileUrl); + club.updateFeedImages(feedImages); + clubRepository.save(club); + } + + @Override + @Transactional + public void completeCoverUpload(String clubId, String fileUrl) { + validateFileConstraints(fileUrl); Club club = getClub(clubId); + validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getCover() != null) { deleteFile(club, club.getClubRecruitmentInformation().getCover()); } - club.updateCover(null); + + club.updateCover(fileUrl); clubRepository.save(club); } - private String uploadFile(String clubId, MultipartFile file, FileType fileType) { - if (file == null || file.isEmpty()) { + + private void validateFileConstraints(String fileUrl) { + if (fileUrl == null || fileUrl.length() > maxFileUrlLength) { + throw new RestApiException(ErrorCode.INVALID_FILE_URL); + } + String key = extractKeyOrNull(fileUrl); + if (key == null) { + throw new RestApiException(ErrorCode.INVALID_FILE_URL); + } + // R2 HEAD로 사이즈 확인 + try { + HeadObjectRequest headReq = HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + long contentLength = s3Client.headObject(headReq).contentLength(); + if (contentLength > maxImageSizeBytes) { + // 초과 시 삭제 후 예외 + try { + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + s3Client.deleteObject(deleteRequest); + } catch (S3Exception e) { + log.warn("Failed to delete oversized object from R2: key={}, error={}", key, e.getMessage()); + } + throw new RestApiException(ErrorCode.FILE_TOO_LARGE); + } + } catch (NoSuchKeyException e) { throw new RestApiException(ErrorCode.FILE_NOT_FOUND); + } catch (S3Exception e) { + throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED); } + } - // 파일명 처리 - String fileName = file.getOriginalFilename(); - - if (!isImageExtension(fileName)) { - throw new RestApiException(ErrorCode.UNSUPPORTED_FILE_TYPE); + /** + * viewEndpoint 접두를 검증하고 S3 key를 추출한다. + * 접두 불일치나 비정상 URL이면 null을 반환한다(호출자에서 처리). + */ + private String extractKeyOrNull(String fileUrl) { + String prefix = normalizedViewEndpoint + "/"; + if (fileUrl == null || !fileUrl.startsWith(prefix)) { + return null; } + return fileUrl.substring(prefix.length()); + } + + private PresignedUploadResponse generatePresignedUrl(String clubId, String fileName, String contentType, FileType fileType) { + // 파일명 처리 + String processedFileName = fileName; if (containsInvalidChars(fileName)) { - fileName = RandomStringUtil.generateRandomString(10); - } - if (file.getSize() > MAX_SIZE) { - try { - file = resizeImage(file, MAX_SIZE); - } catch (IOException e) { - throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); + String extension = ""; + if (fileName.contains(".")) { + extension = fileName.substring(fileName.lastIndexOf(".")); } + processedFileName = RandomStringUtil.generateRandomString(10) + extension; } // S3에 저장할 key 경로 생성 - String key = clubId + "/" + fileType + "/" + fileName; + String key = clubId + "/" + fileType.getPath() + "/" + processedFileName; - // S3 업로드 요청 - try { - PutObjectRequest putRequest = PutObjectRequest.builder() - .bucket(bucketName) - .key(key) - .contentType(file.getContentType()) - .acl(ObjectCannedACL.PUBLIC_READ) // 공개 URL 용도 - .build(); - - s3Client.putObject(putRequest, RequestBody.fromInputStream( - file.getInputStream(), - file.getSize() - )); - - } catch (IOException e) { - throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); - } catch (Exception e) { - throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED); + // PutObjectRequest 생성 + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(contentType) + .build(); + + // Presigned URL 생성 (10분 유효) + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String presignedUrl = presignedRequest.url().toString(); + // 정규화된 viewEndpoint 사용하여 finalUrl 생성 + String finalUrl = normalizedViewEndpoint + "/" + key; + + // 클라이언트가 파일 업로드 시 필요한 헤더 정보 생성 + Map requiredHeaders = new HashMap<>(); + requiredHeaders.put("Content-Type", contentType); + + return new PresignedUploadResponse(presignedUrl, finalUrl, requiredHeaders); + } + + private void validateFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + throw new RestApiException(ErrorCode.FILE_NOT_FOUND); + } + if (!isImageExtension(fileName)) { + throw new RestApiException(ErrorCode.UNSUPPORTED_FILE_TYPE); } + } - // 공유 가능한 공개 URL 반환 - return viewEndpoint + "/" + key; + private void validateClubRecruitmentInformation(Club club) { + if (club.getClubRecruitmentInformation() == null) { + log.error("ClubRecruitmentInformation is null for club: {}", club.getId()); + throw new RestApiException(ErrorCode.CLUB_INFORMATION_NOT_FOUND); + } } } diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java index a4086c78f..6b4d7710a 100644 --- a/backend/src/main/java/moadong/media/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -2,21 +2,28 @@ import java.util.List; import moadong.club.entity.Club; -import org.springframework.web.multipart.MultipartFile; +import moadong.media.dto.PresignedUploadResponse; public interface ClubImageService { - String uploadLogo(String clubId, MultipartFile file); - void deleteLogo(String clubId); - String uploadFeed(String clubId, MultipartFile file); - void updateFeeds(String clubId, List newFeedImageList); void deleteFile(Club club, String filePath); - String uploadCover(String clubId, MultipartFile file); - void deleteCover(String clubId); + + // Presigned URL 방식 메서드 + PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileName, String contentType); + + PresignedUploadResponse generateFeedUploadUrl(String clubId, String fileName, String contentType); + + PresignedUploadResponse generateCoverUploadUrl(String clubId, String fileName, String contentType); + + void completeLogoUpload(String clubId, String fileUrl); + + void completeFeedUpload(String clubId, String fileUrl); + + void completeCoverUpload(String clubId, String fileUrl); } diff --git a/backend/src/main/java/moadong/media/util/S3Config.java b/backend/src/main/java/moadong/media/util/S3Config.java index 83c2fdbe5..2d222c8a7 100644 --- a/backend/src/main/java/moadong/media/util/S3Config.java +++ b/backend/src/main/java/moadong/media/util/S3Config.java @@ -9,6 +9,7 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { @@ -24,6 +25,7 @@ public class S3Config { @Bean public S3Client s3Client() { + validateCredentials(); return S3Client.builder() .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) .endpointOverride(URI.create(endpoint)) @@ -34,5 +36,25 @@ public S3Client s3Client() { .build(); } + @Bean(destroyMethod = "close") + public S3Presigner s3Presigner() { + validateCredentials(); + return S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .endpointOverride(URI.create(endpoint)) + .region(Region.US_EAST_1) + .build(); + } + + private void validateCredentials() { + if (accessKey == null || accessKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { + throw new IllegalStateException("AWS credentials (accessKey, secretKey) must be configured"); + } + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalStateException("AWS S3 endpoint must be configured"); + } + } + } From c6c3bb1524c8ef6a588cc4773d68c1d659b8759f Mon Sep 17 00:00:00 2001 From: yw6938 Date: Sun, 9 Nov 2025 03:20:45 +0900 Subject: [PATCH 02/69] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudflareClubImageServiceFeedTest.java | 129 ------------------ .../CloudflareClubImageServiceLogoTest.java | 127 ----------------- 2 files changed, 256 deletions(-) delete mode 100644 backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java delete mode 100644 backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java diff --git a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java b/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java deleted file mode 100644 index ad67158fe..000000000 --- a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceFeedTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package moadong.media.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import moadong.club.entity.Club; -import moadong.club.entity.ClubRecruitmentInformation; -import moadong.club.repository.ClubRepository; -import moadong.global.exception.ErrorCode; -import moadong.global.exception.RestApiException; -import moadong.util.annotations.UnitTest; -import org.bson.types.ObjectId; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.multipart.MultipartFile; - -@ExtendWith(MockitoExtension.class) -@UnitTest -class CloudflareClubImageServiceFeedTest { - - @Spy - @InjectMocks - private CloudflareImageService clubImageService; - - @Mock - private ClubRepository clubRepository; - - private final int MAX_FEED_COUNT = 5; - - private Club club; - - private ObjectId objectId; - - private final MultipartFile mockFile = new MockMultipartFile( - "testFile", "testFile.jpg", "image/jpeg", "test".getBytes()); - - @BeforeEach - void setUp() { - ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() - .feedImages(List.of()) - .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - objectId = new ObjectId(); - ReflectionTestUtils.setField(clubImageService, "MAX_FEED_COUNT", 5); - } - - // uploadFeed - @Test - void MAX_FEED_COUNT_이상의_피드를_업로드하면_TOO_MANY_FILES를_반환한다() { - // given - List feedImages = Arrays.asList(new String[MAX_FEED_COUNT]); - ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() - .feedImages(feedImages) - .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile)); - assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); - } - - @Test - void feed를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { - when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); - assertThrows(RestApiException.class, () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile)); - } - - // updateFeeds - @Test - void 새로운_feed_리스트를_넣으면_정상적으로_저장된다() { - // given - List feedImages = List.of("old1.jpg", "old2.jpg"); - ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() - .feedImages(feedImages) - .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - List newList = List.of("new1.jpg"); - when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); - doNothing().when(clubImageService).deleteFile(eq(club), any()); - - // when - clubImageService.updateFeeds(objectId.toHexString(), newList); - - // then - assertIterableEquals(newList, club.getClubRecruitmentInformation().getFeedImages()); - - } - - @Test - void feed를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { - // given - when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.updateFeeds(objectId.toHexString(), List.of())); - assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); - } - - @Test - void 새로운_feed_리스트가_MAX_FEED_COUNT_이상이면_TOO_MANY_FILES를_반환한다() { - // given - when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); - List tooMany = Arrays.asList(new String[MAX_FEED_COUNT + 1]); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.updateFeeds(objectId.toHexString(), tooMany)); - assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); - } - -} diff --git a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java b/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java deleted file mode 100644 index 8b0808324..000000000 --- a/backend/src/test/java/moadong/media/service/CloudflareClubImageServiceLogoTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package moadong.media.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import moadong.club.entity.Club; -import moadong.club.entity.ClubRecruitmentInformation; -import moadong.club.repository.ClubRepository; -import moadong.global.exception.ErrorCode; -import moadong.global.exception.RestApiException; -import moadong.media.util.ClubImageUtil; -import moadong.util.annotations.UnitTest; -import org.bson.types.ObjectId; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; - -@ExtendWith(MockitoExtension.class) -@UnitTest -class CloudflareClubImageServiceLogoTest { - - @Spy - @InjectMocks - private CloudflareImageService clubImageService; - - @Mock - private ClubRepository clubRepository; - - private Club club; - - private ObjectId objectId; - - private final MultipartFile mockFile = new MockMultipartFile( - "testFile", "testFile.jpg", "image/jpeg", "test".getBytes()); - - @BeforeEach - void setUp() throws NoSuchMethodException { - ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() - .logo(null) - .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - objectId = new ObjectId(); - } - - @Test - void logo를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { - // given - when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.uploadLogo(objectId.toHexString(), mockFile)); - assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); - } - - @Test - void 업로드하는_파일이_없다면_FILE_NOT_FOUND를_반환한다() { - // given - when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.uploadLogo(objectId.toHexString(), null)); - assertEquals(ErrorCode.FILE_NOT_FOUND, exception.getErrorCode()); - } - - // deleteLogo - @Test - void 로고를_삭제하면_logo값이_null이_된다() { - // given - ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() - .logo("test link") - .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); - doNothing().when(clubImageService).deleteFile(club, "test link"); - - // when - clubImageService.deleteLogo(objectId.toHexString()); - - // then - assertNull(club.getClubRecruitmentInformation().getLogo()); - } - - @Test - void logo를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { - // given - when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); - - // when & then - RestApiException exception = assertThrows(RestApiException.class, - () -> clubImageService.deleteLogo(objectId.toHexString())); - assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); - } - - @Test - void 파일명에_적절하지않은_기호나_한글이_포함되어있으면_true를_반환한다(){ - // 정상 케이스 - assertFalse(ClubImageUtil.containsInvalidChars("normal-file.jpg")); - - // 한글 포함 - assertTrue(ClubImageUtil.containsInvalidChars("file 이름.png")); - - // 퍼센트 인코딩 포함 - assertTrue(ClubImageUtil.containsInvalidChars("file%20name.png")); - assertTrue(ClubImageUtil.containsInvalidChars("%3Ahidden.jpg")); - - // 공백 포함 - assertTrue(ClubImageUtil.containsInvalidChars("file name.png")); - assertTrue(ClubImageUtil.containsInvalidChars(" tab\tname.png")); - - // 여러 조건 섞인 경우 - assertTrue(ClubImageUtil.containsInvalidChars("%22한글 space.png")); - } -} From 66dcafd86d41f855215ce3498820b2aa92d2cf3f Mon Sep 17 00:00:00 2001 From: yw6938 Date: Sun, 9 Nov 2025 04:07:41 +0900 Subject: [PATCH 03/69] =?UTF-8?q?feat:=20=ED=81=B4=EB=9F=BD=EB=B3=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/service/CloudflareImageService.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index 0198fa908..44dff9b38 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -52,7 +52,7 @@ public class CloudflareImageService implements ClubImageService{ private String viewEndpoint; @Value("${server.image.max-size}") private long maxImageSizeBytes; - @Value("${server.file-url.max-length}") + @Value("${server.file-url.max-length:200}") private int maxFileUrlLength; private String normalizedViewEndpoint; @@ -88,13 +88,13 @@ public void updateFeeds(String clubId, List newFeedImageList) { List existing = club.getClubRecruitmentInformation().getFeedImages(); if (existing == null || existing.isEmpty()) { for (String url : newFeedImageList) { - validateFileConstraints(url); + validateFileConstraints(club.getId(), FileType.FEED, url); } } else { java.util.HashSet existingSet = new java.util.HashSet<>(existing); for (String url : newFeedImageList) { if (!existingSet.contains(url)) { - validateFileConstraints(url); + validateFileConstraints(club.getId(), FileType.FEED, url); } } } @@ -192,7 +192,7 @@ public PresignedUploadResponse generateCoverUploadUrl(String clubId, String file @Override @Transactional public void completeLogoUpload(String clubId, String fileUrl) { - validateFileConstraints(fileUrl); + validateFileConstraints(clubId, FileType.LOGO, fileUrl); Club club = getClub(clubId); validateClubRecruitmentInformation(club); @@ -207,7 +207,7 @@ public void completeLogoUpload(String clubId, String fileUrl) { @Override @Transactional public void completeFeedUpload(String clubId, String fileUrl) { - validateFileConstraints(fileUrl); + validateFileConstraints(clubId, FileType.FEED, fileUrl); // 트랜잭션 내에서 최신 데이터를 다시 조회하여 Race Condition 방지 Club club = getClub(clubId); @@ -231,7 +231,7 @@ public void completeFeedUpload(String clubId, String fileUrl) { @Override @Transactional public void completeCoverUpload(String clubId, String fileUrl) { - validateFileConstraints(fileUrl); + validateFileConstraints(clubId, FileType.COVER, fileUrl); Club club = getClub(clubId); validateClubRecruitmentInformation(club); @@ -244,12 +244,12 @@ public void completeCoverUpload(String clubId, String fileUrl) { } - private void validateFileConstraints(String fileUrl) { + private void validateFileConstraints(String clubId, FileType fileType, String fileUrl) { if (fileUrl == null || fileUrl.length() > maxFileUrlLength) { throw new RestApiException(ErrorCode.INVALID_FILE_URL); } String key = extractKeyOrNull(fileUrl); - if (key == null) { + if (key == null || !key.startsWith(clubId + "/" + fileType.getPath() + "/")) { throw new RestApiException(ErrorCode.INVALID_FILE_URL); } // R2 HEAD로 사이즈 확인 From 32792aacab440d14d2c61372c0c6b6253d964ea2 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 12 Nov 2025 14:52:01 +0900 Subject: [PATCH 04/69] =?UTF-8?q?feat:=20=EC=A4=91=EB=B3=B5=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/ClubImageController.java | 5 +- .../media/service/CloudflareImageService.java | 48 +++++++------------ .../media/service/ClubImageService.java | 2 +- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index f725ae910..d324b20a8 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -7,6 +7,7 @@ import moadong.media.dto.FeedUpdateRequest; import moadong.media.dto.PresignedUploadResponse; import moadong.media.dto.UploadCompleteRequest; +import moadong.media.dto.UploadCompleteListRequest; import moadong.media.dto.UploadUrlRequest; import moadong.media.service.ClubImageService; import org.springframework.beans.factory.annotation.Qualifier; @@ -83,8 +84,8 @@ public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, @PostMapping("/{clubId}/feed/complete") @Operation(summary = "피드 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") public ResponseEntity completeFeedUpload(@PathVariable String clubId, - @RequestBody UploadCompleteRequest request) { - clubImageService.completeFeedUpload(clubId, request.fileUrl()); + @RequestBody UploadCompleteListRequest request) { + clubImageService.completeFeedUpload(clubId, request.fileUrls()); return Response.ok("success upload feed"); } diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index 44dff9b38..fdf5f2ee8 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -5,7 +5,6 @@ import jakarta.annotation.PostConstruct; import java.time.Duration; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -79,25 +78,10 @@ public void deleteLogo(String clubId) { @Transactional public void updateFeeds(String clubId, List newFeedImageList) { Club club = getClub(clubId); - validateClubRecruitmentInformation(club); if (newFeedImageList.size() > MAX_FEED_COUNT) { throw new RestApiException(ErrorCode.TOO_MANY_FILES); } - // 각 URL에 대한 제약 검증 수행(기존 URL은 스킵) - List existing = club.getClubRecruitmentInformation().getFeedImages(); - if (existing == null || existing.isEmpty()) { - for (String url : newFeedImageList) { - validateFileConstraints(club.getId(), FileType.FEED, url); - } - } else { - java.util.HashSet existingSet = new java.util.HashSet<>(existing); - for (String url : newFeedImageList) { - if (!existingSet.contains(url)) { - validateFileConstraints(club.getId(), FileType.FEED, url); - } - } - } List feedImages = club.getClubRecruitmentInformation().getFeedImages(); if (feedImages != null && !feedImages.isEmpty()) { @@ -204,28 +188,28 @@ public void completeLogoUpload(String clubId, String fileUrl) { clubRepository.save(club); } - @Override - @Transactional - public void completeFeedUpload(String clubId, String fileUrl) { - validateFileConstraints(clubId, FileType.FEED, fileUrl); - // 트랜잭션 내에서 최신 데이터를 다시 조회하여 Race Condition 방지 + @Transactional + public void completeFeedUpload(String clubId, List newFeedImageList) { Club club = getClub(clubId); - validateClubRecruitmentInformation(club); - List feedImages = club.getClubRecruitmentInformation().getFeedImages(); - if (feedImages == null) { - feedImages = new ArrayList<>(); - } - - // 동시 요청 시 최신 상태를 다시 확인 - if (feedImages.size() + 1 > MAX_FEED_COUNT) { + // 각 URL에 대한 제약 검증 수행(기존 URL은 스킵) + List existing = club.getClubRecruitmentInformation().getFeedImages(); + java.util.HashSet existingSet = existing == null ? new java.util.HashSet<>() : new java.util.HashSet<>(existing); + java.util.HashSet newDistinct = new java.util.HashSet<>(); + for (String url : newFeedImageList) { + if (!existingSet.contains(url)) { + validateFileConstraints(club.getId(), FileType.FEED, url); + newDistinct.add(url); + } + } + + // 총 개수 검증: 기존 + 신규(중복 제외) <= MAX_FEED_COUNT + int existingCount = existing == null ? 0 : existing.size(); + if (existingCount + newDistinct.size() > MAX_FEED_COUNT) { throw new RestApiException(ErrorCode.TOO_MANY_FILES); } - feedImages.add(fileUrl); - club.updateFeedImages(feedImages); - clubRepository.save(club); } @Override diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java index 6b4d7710a..0ceafc123 100644 --- a/backend/src/main/java/moadong/media/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -23,7 +23,7 @@ public interface ClubImageService { void completeLogoUpload(String clubId, String fileUrl); - void completeFeedUpload(String clubId, String fileUrl); + void completeFeedUpload(String clubId, List newFeedImageList); void completeCoverUpload(String clubId, String fileUrl); } From 9ce05c69230bb0485ae399642753eb6f8191ec90 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 12 Nov 2025 14:56:17 +0900 Subject: [PATCH 05/69] =?UTF-8?q?feat:=20=EC=A4=91=EB=B3=B5=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/media/dto/UploadCompleteListRequest.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java diff --git a/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java b/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java new file mode 100644 index 000000000..3f65c1432 --- /dev/null +++ b/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java @@ -0,0 +1,8 @@ +package moadong.media.dto; + +import java.util.List; + +public record UploadCompleteListRequest(List fileUrls) { +} + + From 33cb9544b1fd9f8f555890c45438d77833b52691 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 12 Nov 2025 17:58:24 +0900 Subject: [PATCH 06/69] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EA=B3=BC=EC=A0=95=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/ClubImageController.java | 9 +-- .../media/dto/UploadCompleteListRequest.java | 8 --- .../media/service/CloudflareImageService.java | 56 ++++++++----------- .../media/service/ClubImageService.java | 2 - 4 files changed, 23 insertions(+), 52 deletions(-) delete mode 100644 backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index d324b20a8..7c72e6085 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -7,7 +7,6 @@ import moadong.media.dto.FeedUpdateRequest; import moadong.media.dto.PresignedUploadResponse; import moadong.media.dto.UploadCompleteRequest; -import moadong.media.dto.UploadCompleteListRequest; import moadong.media.dto.UploadUrlRequest; import moadong.media.service.ClubImageService; import org.springframework.beans.factory.annotation.Qualifier; @@ -81,13 +80,7 @@ public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, return Response.ok(response); } - @PostMapping("/{clubId}/feed/complete") - @Operation(summary = "피드 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") - public ResponseEntity completeFeedUpload(@PathVariable String clubId, - @RequestBody UploadCompleteListRequest request) { - clubImageService.completeFeedUpload(clubId, request.fileUrls()); - return Response.ok("success upload feed"); - } + // feed complete API는 더 이상 사용하지 않습니다. (검증은 updateFeeds에서 수행) @PostMapping("/{clubId}/cover/upload-url") @Operation(summary = "커버 이미지 업로드 URL 생성", description = "커버 이미지 업로드를 위한 Presigned URL을 생성합니다.") diff --git a/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java b/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java deleted file mode 100644 index 3f65c1432..000000000 --- a/backend/src/main/java/moadong/media/dto/UploadCompleteListRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package moadong.media.dto; - -import java.util.List; - -public record UploadCompleteListRequest(List fileUrls) { -} - - diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index fdf5f2ee8..b782a349c 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -77,18 +77,30 @@ public void deleteLogo(String clubId) { @Override @Transactional public void updateFeeds(String clubId, List newFeedImageList) { - Club club = getClub(clubId); + Club club = getClub(clubId); + validateClubRecruitmentInformation(club); - if (newFeedImageList.size() > MAX_FEED_COUNT) { - throw new RestApiException(ErrorCode.TOO_MANY_FILES); - } + if (newFeedImageList == null) { + newFeedImageList = java.util.Collections.emptyList(); + } - List feedImages = club.getClubRecruitmentInformation().getFeedImages(); - if (feedImages != null && !feedImages.isEmpty()) { - deleteFeedImages(club, feedImages, newFeedImageList); - } - club.updateFeedImages(newFeedImageList); - clubRepository.save(club); + if (newFeedImageList.size() > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + + //리스트에 대해 URL 제약 검증 + for (String url : newFeedImageList) { + validateFileConstraints(club.getId(), FileType.FEED, url); + } + + //검증 통과 후 누락된 기존 파일만 삭제 + List existingFeedImages = club.getClubRecruitmentInformation().getFeedImages(); + if (existingFeedImages != null && !existingFeedImages.isEmpty()) { + deleteFeedImages(club, existingFeedImages, newFeedImageList); + } + + club.updateFeedImages(newFeedImageList); + clubRepository.save(club); } @@ -188,30 +200,6 @@ public void completeLogoUpload(String clubId, String fileUrl) { clubRepository.save(club); } - - @Transactional - public void completeFeedUpload(String clubId, List newFeedImageList) { - Club club = getClub(clubId); - - // 각 URL에 대한 제약 검증 수행(기존 URL은 스킵) - List existing = club.getClubRecruitmentInformation().getFeedImages(); - java.util.HashSet existingSet = existing == null ? new java.util.HashSet<>() : new java.util.HashSet<>(existing); - java.util.HashSet newDistinct = new java.util.HashSet<>(); - for (String url : newFeedImageList) { - if (!existingSet.contains(url)) { - validateFileConstraints(club.getId(), FileType.FEED, url); - newDistinct.add(url); - } - } - - // 총 개수 검증: 기존 + 신규(중복 제외) <= MAX_FEED_COUNT - int existingCount = existing == null ? 0 : existing.size(); - if (existingCount + newDistinct.size() > MAX_FEED_COUNT) { - throw new RestApiException(ErrorCode.TOO_MANY_FILES); - } - - } - @Override @Transactional public void completeCoverUpload(String clubId, String fileUrl) { diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java index 0ceafc123..4ab7bba6d 100644 --- a/backend/src/main/java/moadong/media/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -23,7 +23,5 @@ public interface ClubImageService { void completeLogoUpload(String clubId, String fileUrl); - void completeFeedUpload(String clubId, List newFeedImageList); - void completeCoverUpload(String clubId, String fileUrl); } From 750243d043e543f876c62463e7353b4b7c8428d8 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Mon, 17 Nov 2025 16:34:49 +0900 Subject: [PATCH 07/69] =?UTF-8?q?feat:=20description=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/media/controller/ClubImageController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index 7c72e6085..736ff3a57 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -33,21 +33,21 @@ public ClubImageController(@Qualifier("cloudflare") ClubImageService clubImageSe private final ClubImageService clubImageService; @DeleteMapping(value = "/{clubId}/logo") - @Operation(summary = "로고 이미지 삭제", description = "cloudflare 상에 로고 이미지를 저장소에서 삭제합니다.") + @Operation(summary = "로고 이미지 삭제", description = "로고 이미지를 저장소에서 삭제합니다.") public ResponseEntity deleteLogo(@PathVariable String clubId) { clubImageService.deleteLogo(clubId); return Response.ok("success delete logo"); } @PostMapping(value = "/{clubId}/feeds") - @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "cloudflare 상에 피드 이미지의 설정을 업데이트 합니다.") + @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "피드 이미지의 설정을 업데이트 합니다.") public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody FeedUpdateRequest feeds) { clubImageService.updateFeeds(clubId, feeds.feeds()); return Response.ok("success put feeds"); } @DeleteMapping(value = "/{clubId}/cover") - @Operation(summary = "커버 이미지 삭제", description = "cloudflare 상에 커버 이미지를 저장소에서 삭제합니다.") + @Operation(summary = "커버 이미지 삭제", description = "커버 이미지를 저장소에서 삭제합니다.") public ResponseEntity deleteCover(@PathVariable String clubId) { clubImageService.deleteCover(clubId); return Response.ok("success delete cover"); From 3fd991559522610daf29bcfcd714c4e19b50b96a Mon Sep 17 00:00:00 2001 From: yw6938 Date: Mon, 17 Nov 2025 18:00:01 +0900 Subject: [PATCH 08/69] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=EB=AA=85?= =?UTF-8?q?=EC=9D=84=20=EB=82=9C=EC=88=98=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/service/CloudflareImageService.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index b782a349c..23f248382 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -1,6 +1,5 @@ package moadong.media.service; -import static moadong.media.util.ClubImageUtil.containsInvalidChars; import static moadong.media.util.ClubImageUtil.isImageExtension; import jakarta.annotation.PostConstruct; @@ -264,15 +263,11 @@ private String extractKeyOrNull(String fileUrl) { } private PresignedUploadResponse generatePresignedUrl(String clubId, String fileName, String contentType, FileType fileType) { - // 파일명 처리 - String processedFileName = fileName; - if (containsInvalidChars(fileName)) { - String extension = ""; - if (fileName.contains(".")) { - extension = fileName.substring(fileName.lastIndexOf(".")); - } - processedFileName = RandomStringUtil.generateRandomString(10) + extension; + String extension = ""; + if (fileName.contains(".")) { + extension = fileName.substring(fileName.lastIndexOf(".")); } + String processedFileName = RandomStringUtil.generateRandomString(10) + extension; // S3에 저장할 key 경로 생성 String key = clubId + "/" + fileType.getPath() + "/" + processedFileName; From 84c660200f23afa51e7701bf76e11bb2451d5cab Mon Sep 17 00:00:00 2001 From: yw6938 Date: Mon, 17 Nov 2025 19:01:18 +0900 Subject: [PATCH 09/69] =?UTF-8?q?refactor:=20=EB=A7=8C=EB=A3=8C=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20expirationTime=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/media/service/CloudflareImageService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index 23f248382..dd9897bf3 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -52,6 +52,8 @@ public class CloudflareImageService implements ClubImageService{ private long maxImageSizeBytes; @Value("${server.file-url.max-length:200}") private int maxFileUrlLength; + @Value("${server.file-url.expiration-time:10}") + private int expirationTime; private String normalizedViewEndpoint; @PostConstruct @@ -281,7 +283,7 @@ private PresignedUploadResponse generatePresignedUrl(String clubId, String fileN // Presigned URL 생성 (10분 유효) PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(10)) + .signatureDuration(Duration.ofMinutes(expirationTime)) .putObjectRequest(putObjectRequest) .build(); From dab01a934fa22d92f4c54d2bad4478c3e8ab7069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:56:38 +0900 Subject: [PATCH 10/69] =?UTF-8?q?feat:=20applicationForm=20delete=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubApplyAdminController.java | 10 ++++++++++ .../club/repository/ClubApplicantsRepository.java | 2 ++ .../moadong/club/service/ClubApplyAdminService.java | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java index ed38de60a..44c50f36b 100644 --- a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java +++ b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java @@ -69,6 +69,16 @@ public ResponseEntity getClubApplications(@CurrentUser CustomUserDetails user return Response.ok(clubApplyAdminService.getClubApplicationForms(user)); } + @DeleteMapping("/application/{applicationFormId}") + @Operation(summary = "클럽 지원서 양식 삭제", description = "클럽의 지원서 양식을 삭제합니다") + @PreAuthorize("isAuthenticated()") + @SecurityRequirement(name = "BearerAuth") + public ResponseEntity deleteClubApplicationForm(@PathVariable String applicationFormId, + @CurrentUser CustomUserDetails user) { + clubApplyAdminService.deleteClubApplicationForm(applicationFormId, user); + return Response.ok("success delete application"); + } + @GetMapping("/apply/info/{applicationFormId}") @Operation(summary = "클럽 지원자 현황", description = "클럽 지원자 현황을 불러옵니다") @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java b/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java index 9d36cc136..616f2cdb6 100644 --- a/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java @@ -10,4 +10,6 @@ public interface ClubApplicantsRepository extends MongoRepository findAllByFormId(String questionId); List findAllByIdInAndFormId(List ids, String formId); + + void deleteAllByFormId(String formId); } \ No newline at end of file diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index dd9f705f8..fc6a058d6 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -119,6 +119,15 @@ public ClubApplicationFormsResponse getClubApplicationForms(CustomUserDetails us .build(); } + @Transactional + public void deleteClubApplicationForm(String applicationFormId, CustomUserDetails user) { + ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId) + .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + + clubApplicantsRepository.deleteAllByFormId(applicationForm.getId()); + clubApplicationFormsRepository.delete(applicationForm); + } + public ClubApplyInfoResponse getClubApplyInfo(String applicationFormId, CustomUserDetails user) { ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId) .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); From 64d2233517bfafe31b5ffe855d2a5f29357f4939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:25:59 +0900 Subject: [PATCH 11/69] =?UTF-8?q?chore:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20spr?= =?UTF-8?q?ing=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/service/ClubApplyAdminService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index fc6a058d6..8bb0d6852 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -1,6 +1,5 @@ package moadong.club.service; -import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import moadong.club.entity.*; @@ -17,6 +16,7 @@ import moadong.global.util.AESCipher; import moadong.user.payload.CustomUserDetails; 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.servlet.mvc.method.annotation.SseEmitter; From f24166231a48745cde26a4716ec3d40a10ad2693 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 19 Nov 2025 13:38:58 +0900 Subject: [PATCH 12/69] =?UTF-8?q?refactor:=20feedurl=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/ClubImageController.java | 11 +++--- .../media/dto/PresignedUploadResponse.java | 4 +- .../media/service/CloudflareImageService.java | 38 +++++++++++++++---- .../media/service/ClubImageService.java | 3 +- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index 736ff3a57..92b768b1f 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; @RestController @RequestMapping("/api/club") @@ -53,7 +54,6 @@ public ResponseEntity deleteCover(@PathVariable String clubId) { return Response.ok("success delete cover"); } - // Presigned URL 방식 엔드포인트 @PostMapping("/{clubId}/logo/upload-url") @Operation(summary = "로고 이미지 업로드 URL 생성", description = "로고 이미지 업로드를 위한 Presigned URL을 생성합니다.") public ResponseEntity generateLogoUploadUrl(@PathVariable String clubId, @@ -72,12 +72,11 @@ public ResponseEntity completeLogoUpload(@PathVariable String clubId, } @PostMapping("/{clubId}/feed/upload-url") - @Operation(summary = "피드 이미지 업로드 URL 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 생성합니다.") + @Operation(summary = "피드 이미지 업로드 URL들 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 여러 개 한 번에 생성합니다.") public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, - @RequestBody UploadUrlRequest request) { - PresignedUploadResponse response = clubImageService.generateFeedUploadUrl( - clubId, request.fileName(), request.contentType()); - return Response.ok(response); + @RequestBody List requests) { + List results = clubImageService.generateFeedUploadUrls(clubId, requests); + return Response.ok(results); } // feed complete API는 더 이상 사용하지 않습니다. (검증은 updateFeeds에서 수행) diff --git a/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java index d43a6e168..d40bd4a8a 100644 --- a/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java +++ b/backend/src/main/java/moadong/media/dto/PresignedUploadResponse.java @@ -5,7 +5,9 @@ public record PresignedUploadResponse( String presignedUrl, String finalUrl, - Map requiredHeaders + Map requiredHeaders, + boolean success, + String failureReason ) { } diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index dd9897bf3..e566d6cc6 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -17,6 +17,7 @@ import moadong.global.util.RandomStringUtil; import moadong.media.domain.FileType; import moadong.media.dto.PresignedUploadResponse; +import moadong.media.dto.UploadUrlRequest; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -167,17 +168,35 @@ public PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileN } @Override - public PresignedUploadResponse generateFeedUploadUrl(String clubId, String fileName, String contentType) { - validateFileName(fileName); + public List generateFeedUploadUrls(String clubId, List requests) { Club club = getClub(clubId); validateClubRecruitmentInformation(club); + int existingCount = (club.getClubRecruitmentInformation().getFeedImages() == null) + ? 0 + : club.getClubRecruitmentInformation().getFeedImages().size(); + int remaining = Math.max(0, MAX_FEED_COUNT - existingCount); + if (remaining == 0) { + return java.util.List.of(errorResponse(ErrorCode.TOO_MANY_FILES)); + } - List feedImages = club.getClubRecruitmentInformation().getFeedImages(); - int feedImagesCount = (feedImages == null) ? 0 : feedImages.size(); - if (feedImagesCount + 1 > MAX_FEED_COUNT) { - throw new RestApiException(ErrorCode.TOO_MANY_FILES); + int limit = Math.min(remaining, requests.size()); + java.util.ArrayList results = new java.util.ArrayList<>(limit + 1); + for (int i = 0; i < limit; i++) { + UploadUrlRequest req = requests.get(i); + try { + validateFileName(req.fileName()); + results.add(generatePresignedUrl(clubId, req.fileName(), req.contentType(), FileType.FEED)); + } catch (RestApiException e) { + results.add(errorResponse(e.getErrorCode())); + } catch (Exception e) { + log.error("Unexpected error generating presigned URL: clubId={}, fileName={}", clubId, req.fileName(), e); + results.add(errorResponse(ErrorCode.IMAGE_UPLOAD_FAILED)); + } } - return generatePresignedUrl(clubId, fileName, contentType, FileType.FEED); + if (requests.size() > limit) { + results.add(errorResponse(ErrorCode.TOO_MANY_FILES)); + } + return results; } @Override @@ -296,9 +315,12 @@ private PresignedUploadResponse generatePresignedUrl(String clubId, String fileN Map requiredHeaders = new HashMap<>(); requiredHeaders.put("Content-Type", contentType); - return new PresignedUploadResponse(presignedUrl, finalUrl, requiredHeaders); + return new PresignedUploadResponse(presignedUrl, finalUrl, requiredHeaders, true, null); } + private PresignedUploadResponse errorResponse(ErrorCode code) { + return new PresignedUploadResponse(null, null, null, false, code.getMessage()); + } private void validateFileName(String fileName) { if (fileName == null || fileName.isEmpty()) { throw new RestApiException(ErrorCode.FILE_NOT_FOUND); diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java index 4ab7bba6d..751319fd0 100644 --- a/backend/src/main/java/moadong/media/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -3,6 +3,7 @@ import java.util.List; import moadong.club.entity.Club; import moadong.media.dto.PresignedUploadResponse; +import moadong.media.dto.UploadUrlRequest; public interface ClubImageService { @@ -17,7 +18,7 @@ public interface ClubImageService { // Presigned URL 방식 메서드 PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileName, String contentType); - PresignedUploadResponse generateFeedUploadUrl(String clubId, String fileName, String contentType); + List generateFeedUploadUrls(String clubId, List requests); PresignedUploadResponse generateCoverUploadUrl(String clubId, String fileName, String contentType); From 0ec2ff42dcb8f6fee0d90c8446f2bf29678dc42c Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 19 Nov 2025 14:08:02 +0900 Subject: [PATCH 13/69] =?UTF-8?q?refactor:=20dto=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/ClubImageController.java | 13 +++++++------ .../java/moadong/media/dto/FeedUpdateRequest.java | 6 +++++- .../moadong/media/dto/UploadCompleteRequest.java | 7 ++++++- .../java/moadong/media/dto/UploadUrlRequest.java | 11 ++++++++++- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index 92b768b1f..3921b678d 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -42,7 +43,7 @@ public ResponseEntity deleteLogo(@PathVariable String clubId) { @PostMapping(value = "/{clubId}/feeds") @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "피드 이미지의 설정을 업데이트 합니다.") - public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody FeedUpdateRequest feeds) { + public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody @Valid FeedUpdateRequest feeds) { clubImageService.updateFeeds(clubId, feeds.feeds()); return Response.ok("success put feeds"); } @@ -57,7 +58,7 @@ public ResponseEntity deleteCover(@PathVariable String clubId) { @PostMapping("/{clubId}/logo/upload-url") @Operation(summary = "로고 이미지 업로드 URL 생성", description = "로고 이미지 업로드를 위한 Presigned URL을 생성합니다.") public ResponseEntity generateLogoUploadUrl(@PathVariable String clubId, - @RequestBody UploadUrlRequest request) { + @RequestBody @Valid UploadUrlRequest request) { PresignedUploadResponse response = clubImageService.generateLogoUploadUrl( clubId, request.fileName(), request.contentType()); return Response.ok(response); @@ -66,7 +67,7 @@ public ResponseEntity generateLogoUploadUrl(@PathVariable String clubId, @PostMapping("/{clubId}/logo/complete") @Operation(summary = "로고 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") public ResponseEntity completeLogoUpload(@PathVariable String clubId, - @RequestBody UploadCompleteRequest request) { + @RequestBody @Valid UploadCompleteRequest request) { clubImageService.completeLogoUpload(clubId, request.fileUrl()); return Response.ok("success upload logo"); } @@ -74,7 +75,7 @@ public ResponseEntity completeLogoUpload(@PathVariable String clubId, @PostMapping("/{clubId}/feed/upload-url") @Operation(summary = "피드 이미지 업로드 URL들 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 여러 개 한 번에 생성합니다.") public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, - @RequestBody List requests) { + @RequestBody @Valid List requests) { List results = clubImageService.generateFeedUploadUrls(clubId, requests); return Response.ok(results); } @@ -84,7 +85,7 @@ public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, @PostMapping("/{clubId}/cover/upload-url") @Operation(summary = "커버 이미지 업로드 URL 생성", description = "커버 이미지 업로드를 위한 Presigned URL을 생성합니다.") public ResponseEntity generateCoverUploadUrl(@PathVariable String clubId, - @RequestBody UploadUrlRequest request) { + @RequestBody @Valid UploadUrlRequest request) { PresignedUploadResponse response = clubImageService.generateCoverUploadUrl( clubId, request.fileName(), request.contentType()); return Response.ok(response); @@ -93,7 +94,7 @@ public ResponseEntity generateCoverUploadUrl(@PathVariable String clubId, @PostMapping("/{clubId}/cover/complete") @Operation(summary = "커버 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") public ResponseEntity completeCoverUpload(@PathVariable String clubId, - @RequestBody UploadCompleteRequest request) { + @RequestBody @Valid UploadCompleteRequest request) { clubImageService.completeCoverUpload(clubId, request.fileUrl()); return Response.ok("success upload cover"); } diff --git a/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java index 6a920d24f..e2e1e8b6a 100644 --- a/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java +++ b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java @@ -1,6 +1,10 @@ package moadong.media.dto; +import jakarta.validation.constraints.NotNull; import java.util.List; -public record FeedUpdateRequest(List feeds) { +public record FeedUpdateRequest( + @NotNull + List feeds +) { } diff --git a/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java index 58734e68e..6348eae93 100644 --- a/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java +++ b/backend/src/main/java/moadong/media/dto/UploadCompleteRequest.java @@ -1,5 +1,10 @@ package moadong.media.dto; -public record UploadCompleteRequest(String fileUrl) { +import jakarta.validation.constraints.NotBlank; + +public record UploadCompleteRequest( + @NotBlank + String fileUrl +) { } diff --git a/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java index d8286b881..ee2ae63e5 100644 --- a/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java +++ b/backend/src/main/java/moadong/media/dto/UploadUrlRequest.java @@ -1,5 +1,14 @@ package moadong.media.dto; -public record UploadUrlRequest(String fileName, String contentType) { +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UploadUrlRequest( + @NotBlank + String fileName, + @NotBlank + @Pattern(regexp = "^image/(jpeg|jpg|png|gif|bmp|webp)$", message = "지원되지 않는 MIME 타입입니다.") + String contentType +) { } From 8759bf20b69386737c3b354d8adb35a0d5847493 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 19 Nov 2025 14:36:58 +0900 Subject: [PATCH 14/69] =?UTF-8?q?refactor:=20null=EC=9D=BC=EB=95=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/media/service/CloudflareImageService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index e566d6cc6..3ec55df1d 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -59,8 +59,11 @@ public class CloudflareImageService implements ClubImageService{ @PostConstruct private void init() { + if (viewEndpoint == null || viewEndpoint.isEmpty()) { + throw new IllegalStateException("cloud.aws.s3.view-endpoint must be configured"); + } // viewEndpoint 정규화: 후행 슬래시 제거 - normalizedViewEndpoint = viewEndpoint != null ? viewEndpoint.replaceAll("/+$", "") : ""; + normalizedViewEndpoint = viewEndpoint.replaceAll("/+$", ""); } @Override From f91fde02a120f2d37b23319e69b20710b59a3a1b Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 18:35:23 +0900 Subject: [PATCH 15/69] =?UTF-8?q?feat:=20ApplicationForm=20Mode=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/ClubApplicationForm.java | 5 +++++ .../main/java/moadong/club/enums/ApplicationFormMode.java | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 backend/src/main/java/moadong/club/enums/ApplicationFormMode.java diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 32e21a283..6103bbce1 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -11,6 +11,7 @@ import lombok.Builder; import lombok.Getter; import moadong.club.enums.ApplicantStatus; +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.ApplicationFormStatus; import moadong.club.enums.SemesterTerm; import org.springframework.data.annotation.Version; @@ -57,6 +58,10 @@ public class ClubApplicationForm implements Persistable { @Builder.Default private ApplicationFormStatus status = ApplicationFormStatus.UNPUBLISHED; + @NotNull + @Builder.Default + private ApplicationFormMode formMode = ApplicationFormMode.INTERNAL; + @Version private Long version; diff --git a/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java b/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java new file mode 100644 index 000000000..6a55954b3 --- /dev/null +++ b/backend/src/main/java/moadong/club/enums/ApplicationFormMode.java @@ -0,0 +1,6 @@ +package moadong.club.enums; + +public enum ApplicationFormMode { + INTERNAL, + EXTERNAL +} From 905aaf3f42059ca2f65f51fd0afbaa8847d5d6d4 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 19:41:02 +0900 Subject: [PATCH 16/69] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EC=97=90=20=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=ED=8F=BC=20mode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payload/request/ClubApplicationFormCreateRequest.java | 5 +++++ .../club/payload/request/ClubApplicationFormEditRequest.java | 3 +++ 2 files changed, 8 insertions(+) diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java index 6b9629286..e87e500c3 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java @@ -7,6 +7,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.List; + +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.SemesterTerm; public record ClubApplicationFormCreateRequest( @@ -22,6 +24,9 @@ public record ClubApplicationFormCreateRequest( @Valid List questions, + @NotNull + ApplicationFormMode formMode, + @NotNull @Min(2000) @Max(2999) diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java index cd8442ec3..ba1ab5bed 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.*; +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.SemesterTerm; import java.util.List; @@ -18,6 +19,8 @@ public record ClubApplicationFormEditRequest( @Valid List questions, + ApplicationFormMode formMode, + @Min(2000) @Max(2999) Integer semesterYear, From 6e8bd756a9ff5b6858b550571a2adb1f13268c48 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 19:41:23 +0900 Subject: [PATCH 17/69] =?UTF-8?q?feat:=20ApplicationFormMode=20CU=20api=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/ClubApplicationForm.java | 4 ++++ .../main/java/moadong/club/service/ClubApplyAdminService.java | 3 +++ 2 files changed, 7 insertions(+) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 6103bbce1..75a890e12 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -94,6 +94,10 @@ public void updateFormStatus(boolean activeFlag) { this.status = ApplicationFormStatus.fromFlag(this.status, activeFlag); } + public void updateFormMode(ApplicationFormMode formMode) { + this.formMode = formMode; + } + @Override public boolean isNew() { return this.version == null; diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index dd9f705f8..9bedf163e 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -87,6 +87,7 @@ public void createClubApplicationForm(CustomUserDetails user, ClubApplicationFor ClubApplicationForm clubApplicationForm = createApplicationForm( ClubApplicationForm.builder() .clubId(user.getClubId()) + .formMode(request.formMode()) .build(), request); clubApplicationFormsRepository.save(clubApplicationForm); @@ -234,6 +235,8 @@ private ClubApplicationForm updateApplicationForm(ClubApplicationForm clubApplic clubApplicationForm.updateFormDescription(request.description()); if (request.active() != null) clubApplicationForm.updateFormStatus(request.active()); + if (request.formMode() != null) + clubApplicationForm.updateFormMode(request.formMode()); if (request.semesterYear() != null || request.semesterTerm() != null) { Integer semesterYear = Optional.ofNullable(request.semesterYear()).orElse(clubApplicationForm.getSemesterYear()); From 451d3d13e3551538c636d0f52f9bd4ad2033c8c2 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 19:45:25 +0900 Subject: [PATCH 18/69] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=EC=97=90=20url=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/moadong/club/entity/ClubApplicationForm.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 75a890e12..1deae4029 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -62,6 +62,9 @@ public class ClubApplicationForm implements Persistable { @Builder.Default private ApplicationFormMode formMode = ApplicationFormMode.INTERNAL; + @Builder.Default + private String externalApplicationUrl = ""; + @Version private Long version; From 4e884e35146e4ce96ee55affef57c7a7c183c826 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 20:03:04 +0900 Subject: [PATCH 19/69] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=88=84=EB=9D=BD=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/global/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index bfd60ba91..58586a291 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -54,6 +54,7 @@ public enum ErrorCode { REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."), ACTIVE_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-6", "활성화된 지원서 양식이 존재하지 않습니다."), APPLICATION_SEMESTER_INVALID(HttpStatus.BAD_REQUEST, "800-7", "올바르지 않은 학기입니다."), + APPLICATION_OPTIONS_MISSING(HttpStatus.BAD_REQUEST, "800-8", "지원서 생성에 필요한 필드가 누락되었습니다."), // 900xx: 기타 시스템 오류 AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."), From abf02c8e752f85e061e2c4cb49617c215c037fc4 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 20:03:36 +0900 Subject: [PATCH 20/69] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8=EA=B3=BC=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=EC=A7=80=EC=9B=90=EC=84=9C=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EB=91=98=EC=A4=91=ED=95=98=EB=82=98=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=EC=A7=80=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/ClubApplicationForm.java | 4 ++++ .../payload/request/ClubApplicationFormCreateRequest.java | 3 ++- .../main/java/moadong/club/service/ClubApplyAdminService.java | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 1deae4029..64111e913 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -101,6 +101,10 @@ public void updateFormMode(ApplicationFormMode formMode) { this.formMode = formMode; } + public void updateExternalApplicationUrl(String externalApplicationUrl) { + this.externalApplicationUrl = externalApplicationUrl; + } + @Override public boolean isNew() { return this.version == null; diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java index e87e500c3..3900e4800 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java @@ -20,13 +20,14 @@ public record ClubApplicationFormCreateRequest( @Size(max = 3000) String description, - @NotNull @Valid List questions, @NotNull ApplicationFormMode formMode, + String externalApplicationUrl, + @NotNull @Min(2000) @Max(2999) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index 9bedf163e..215bd5681 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -82,12 +82,12 @@ private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) { } public void createClubApplicationForm(CustomUserDetails user, ClubApplicationFormCreateRequest request) { + if (request.questions() == null || request.externalApplicationUrl() == null) throw new RestApiException(ErrorCode.APPLICATION_OPTIONS_MISSING); validateSemester(request.semesterYear(), request.semesterTerm()); ClubApplicationForm clubApplicationForm = createApplicationForm( ClubApplicationForm.builder() .clubId(user.getClubId()) - .formMode(request.formMode()) .build(), request); clubApplicationFormsRepository.save(clubApplicationForm); @@ -222,6 +222,8 @@ private ClubApplicationForm createApplicationForm(ClubApplicationForm clubApplic clubApplicationForm.updateFormDescription(request.description()); clubApplicationForm.updateSemesterYear(request.semesterYear()); clubApplicationForm.updateSemesterTerm(request.semesterTerm()); + clubApplicationForm.updateFormMode(request.formMode()); + clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl()); return clubApplicationForm; } From ea0be2bea5a774a05e1c5d9f9793760f0cdaff38 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 20:13:37 +0900 Subject: [PATCH 21/69] =?UTF-8?q?feat:=20=ED=97=88=EB=9D=BD=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=99=B8=EB=B6=80=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A7=8C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/entity/ClubApplicationForm.java | 23 ++++++++++++++----- .../moadong/global/exception/ErrorCode.java | 1 + 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 64111e913..6315fc26a 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -2,27 +2,31 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import moadong.club.enums.ApplicantStatus; import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.ApplicationFormStatus; import moadong.club.enums.SemesterTerm; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; import org.springframework.data.annotation.Version; import org.springframework.data.domain.Persistable; import org.springframework.data.mongodb.core.mapping.Document; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + @Document("club_application_forms") @AllArgsConstructor @Getter @Builder(toBuilder = true) public class ClubApplicationForm implements Persistable { + private static String[] externalApplicationUrlAllowed = {"https://forms.gle", "https://docs.google.com/forms", "https://form.naver.com", "https://naver.me"}; @Id private String id; @@ -102,6 +106,13 @@ public void updateFormMode(ApplicationFormMode formMode) { } public void updateExternalApplicationUrl(String externalApplicationUrl) { + boolean allowed = Arrays.stream(externalApplicationUrlAllowed) + .anyMatch(externalApplicationUrl::startsWith); + + if (!allowed) { + throw new RestApiException(ErrorCode.NOT_ALLOWED_EXTERNAL_URL); + } + this.externalApplicationUrl = externalApplicationUrl; } diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index 58586a291..e989e8ff2 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -55,6 +55,7 @@ public enum ErrorCode { ACTIVE_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-6", "활성화된 지원서 양식이 존재하지 않습니다."), APPLICATION_SEMESTER_INVALID(HttpStatus.BAD_REQUEST, "800-7", "올바르지 않은 학기입니다."), APPLICATION_OPTIONS_MISSING(HttpStatus.BAD_REQUEST, "800-8", "지원서 생성에 필요한 필드가 누락되었습니다."), + NOT_ALLOWED_EXTERNAL_URL(HttpStatus.BAD_REQUEST, "800-9", "형식에 맞지않은 외부지원서 URL 입니다."), // 900xx: 기타 시스템 오류 AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."), From 20f0aa9315c5fdad271cc151fd91ce9aa6fa2fce Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 20:14:50 +0900 Subject: [PATCH 22/69] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EB=B6=80=EB=B6=84=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20api=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/payload/request/ClubApplicationFormEditRequest.java | 2 ++ .../main/java/moadong/club/service/ClubApplyAdminService.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java index ba1ab5bed..63a7249e6 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java @@ -19,6 +19,8 @@ public record ClubApplicationFormEditRequest( @Valid List questions, + String externalApplicationUrl, + ApplicationFormMode formMode, @Min(2000) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index 215bd5681..bd296ae16 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -239,6 +239,8 @@ private ClubApplicationForm updateApplicationForm(ClubApplicationForm clubApplic clubApplicationForm.updateFormStatus(request.active()); if (request.formMode() != null) clubApplicationForm.updateFormMode(request.formMode()); + if (request.externalApplicationUrl() != null) + clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl()); if (request.semesterYear() != null || request.semesterTerm() != null) { Integer semesterYear = Optional.ofNullable(request.semesterYear()).orElse(clubApplicationForm.getSemesterYear()); From ddf6b320005e12a66e202923a80f8ad2482124b4 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 19 Nov 2025 20:18:15 +0900 Subject: [PATCH 23/69] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EB=AA=A8=EB=93=9C=EC=9D=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=EB=A5=BC=20=EC=A0=9C=EC=B6=9C=EC=8B=9C=20Not?= =?UTF-8?q?=20Found=20=EC=97=90=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/service/ClubApplyPublicService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java index ef9f2c937..b7afd2684 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java @@ -6,6 +6,7 @@ import moadong.club.entity.ClubApplicationForm; import moadong.club.entity.ClubApplicationFormQuestion; import moadong.club.entity.ClubQuestionAnswer; +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.ClubApplicationQuestionType; import moadong.club.payload.dto.ClubActiveFormResult; import moadong.club.payload.dto.ClubActiveFormSlim; @@ -64,6 +65,7 @@ public ResponseEntity getClubApplicationForm(String clubId, String applicatio public void applyToClub(String clubId, String applicationFormId, ClubApplyRequest request) { ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + if (clubApplicationForm.getFormMode() == ApplicationFormMode.EXTERNAL) throw new RestApiException(ErrorCode.APPLICATION_NOT_FOUND); validateAnswers(request.questions(), clubApplicationForm); List answers = new ArrayList<>(); From 94f2a2f8e63f8e275e2999e9434f1391a1f3a925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:58:13 +0900 Subject: [PATCH 24/69] =?UTF-8?q?refactor:=20=EC=8B=9C=EC=9E=91=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EC=97=90=20=EC=83=81=EC=8B=9C=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/club/util/RecruitmentStateCalculator.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index 289ea8540..d41a8342c 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -32,10 +32,6 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec return ClubRecruitmentStatus.CLOSED; } - if (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) { - return ClubRecruitmentStatus.ALWAYS; - } - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); if (now.isBefore(recruitmentStartDate)) { @@ -46,7 +42,9 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec } if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { - return ClubRecruitmentStatus.OPEN; + return (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) + ? ClubRecruitmentStatus.ALWAYS + : ClubRecruitmentStatus.OPEN; } return ClubRecruitmentStatus.CLOSED; From bf3504585161e38d816dfd11e49325a64ccfab17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 21 Nov 2025 23:31:04 +0900 Subject: [PATCH 25/69] =?UTF-8?q?fix:=20=EC=8B=9C=EC=9E=91=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/util/RecruitmentStateCalculator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index d41a8342c..8e4b89210 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -41,7 +41,7 @@ public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime rec : ClubRecruitmentStatus.CLOSED; } - if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { + if (!now.isBefore(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { return (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) ? ClubRecruitmentStatus.ALWAYS : ClubRecruitmentStatus.OPEN; From 0530ffb079d51f3008afb79437b4ebbe73bcd9b7 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 22 Nov 2025 20:25:42 +0900 Subject: [PATCH 26/69] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EB=A7=81=ED=81=AC=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/ClubApplicationFormResponse.java | 1 + .../club/service/ClubApplyPublicService.java | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java index 72a7dd935..75965cc96 100644 --- a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java +++ b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java @@ -12,6 +12,7 @@ public record ClubApplicationFormResponse( String title, String description, List questions, + String externalApplicationUrl, Integer semesterYear, SemesterTerm semesterTerm, ApplicationFormStatus status diff --git a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java index b7afd2684..ab4ec77c4 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java @@ -55,8 +55,19 @@ public ClubActiveFormsResponse getActiveApplicationForms(String clubId) { public ResponseEntity getClubApplicationForm(String clubId, String applicationFormId) { ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); - - ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder().title(clubApplicationForm.getTitle()).description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")).questions(clubApplicationForm.getQuestions()).semesterYear(clubApplicationForm.getSemesterYear()).semesterTerm(clubApplicationForm.getSemesterTerm()).status(clubApplicationForm.getStatus()).build(); + boolean isInternal = clubApplicationForm.getFormMode() == ApplicationFormMode.INTERNAL; + List questions = isInternal ? clubApplicationForm.getQuestions() : null; + String externalApplicationUrl = isInternal ? null : clubApplicationForm.getExternalApplicationUrl(); + + ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder() + .title(clubApplicationForm.getTitle()) + .description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")) + .questions(questions) + .externalApplicationUrl(externalApplicationUrl) + .semesterYear(clubApplicationForm.getSemesterYear()) + .semesterTerm(clubApplicationForm.getSemesterTerm()) + .status(clubApplicationForm.getStatus()) + .build(); return Response.ok(clubApplicationFormResponse); } @@ -65,7 +76,8 @@ public ResponseEntity getClubApplicationForm(String clubId, String applicatio public void applyToClub(String clubId, String applicationFormId, ClubApplyRequest request) { ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); - if (clubApplicationForm.getFormMode() == ApplicationFormMode.EXTERNAL) throw new RestApiException(ErrorCode.APPLICATION_NOT_FOUND); + if (clubApplicationForm.getFormMode() == ApplicationFormMode.EXTERNAL) + throw new RestApiException(ErrorCode.APPLICATION_NOT_FOUND); validateAnswers(request.questions(), clubApplicationForm); List answers = new ArrayList<>(); From 2d9c08e95de8b15b6c28ab6799a49618a04b3d02 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 22 Nov 2025 20:32:52 +0900 Subject: [PATCH 27/69] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/club/controller/ClubApplyPublicController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java b/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java index 0fcbba2fa..52035d114 100644 --- a/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java +++ b/backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java @@ -34,12 +34,11 @@ public ResponseEntity applyToClub(@PathVariable String clubId, return Response.ok("success apply"); } - @GetMapping("/apply") + @GetMapping("/apply") @Operation(summary = "클럽의 활성화된 지원서 목록 불러오기", description = "클럽의 활성화된 모든 지원서 목록을 불러옵니다") public ResponseEntity getActiveApplicationForms(@PathVariable String clubId) { return Response.ok(clubApplyPublicService.getActiveApplicationForms(clubId)); } - } From e05ce4bbd0dd44dd84b182d96347423b831db906 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 22 Nov 2025 20:33:27 +0900 Subject: [PATCH 28/69] =?UTF-8?q?fix:=20=ED=98=84=EC=9E=AC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EC=96=91=EC=8B=9D=EC=9D=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80?= =?UTF-8?q?=EC=95=8A=EC=9D=84=EC=8B=9C=20=EA=B8=B0=EB=B3=B8=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20getter=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/ClubApplicationForm.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index 6315fc26a..e48a56fd2 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; @Document("club_application_forms") @AllArgsConstructor @@ -72,6 +73,10 @@ public class ClubApplicationForm implements Persistable { @Version private Long version; + public ApplicationFormMode getFormMode() { + return Optional.ofNullable(this.formMode).orElse(ApplicationFormMode.INTERNAL); + } + public void updateFormTitle(String title) { this.title = title; } From 813d87d5e03a7bd7e8bed90984ac7b3f9e2202b9 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 22 Nov 2025 22:04:05 +0900 Subject: [PATCH 29/69] =?UTF-8?q?refactor:=20formMode=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20mode=EC=97=90=20=EC=83=81=EA=B4=80=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20questions=EC=99=80=20externalApplicationUrl=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payload/response/ClubApplicationFormResponse.java | 2 ++ .../moadong/club/service/ClubApplyPublicService.java | 9 +++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java index 75965cc96..b42387e5b 100644 --- a/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java +++ b/backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java @@ -2,6 +2,7 @@ import lombok.Builder; import moadong.club.entity.ClubApplicationFormQuestion; +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.ApplicationFormStatus; import moadong.club.enums.SemesterTerm; @@ -13,6 +14,7 @@ public record ClubApplicationFormResponse( String description, List questions, String externalApplicationUrl, + ApplicationFormMode formMode, Integer semesterYear, SemesterTerm semesterTerm, ApplicationFormStatus status diff --git a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java index ab4ec77c4..8853be996 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java @@ -55,15 +55,12 @@ public ClubActiveFormsResponse getActiveApplicationForms(String clubId) { public ResponseEntity getClubApplicationForm(String clubId, String applicationFormId) { ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); - boolean isInternal = clubApplicationForm.getFormMode() == ApplicationFormMode.INTERNAL; - List questions = isInternal ? clubApplicationForm.getQuestions() : null; - String externalApplicationUrl = isInternal ? null : clubApplicationForm.getExternalApplicationUrl(); - ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder() .title(clubApplicationForm.getTitle()) .description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")) - .questions(questions) - .externalApplicationUrl(externalApplicationUrl) + .questions(clubApplicationForm.getQuestions()) + .formMode(clubApplicationForm.getFormMode()) + .externalApplicationUrl(clubApplicationForm.getExternalApplicationUrl()) .semesterYear(clubApplicationForm.getSemesterYear()) .semesterTerm(clubApplicationForm.getSemesterTerm()) .status(clubApplicationForm.getStatus()) From 56e3f2e066e0a4421a517b4dd19acbbff27d5644 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sat, 22 Nov 2025 22:05:03 +0900 Subject: [PATCH 30/69] =?UTF-8?q?refactor:=20formMode=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=EA=B2=80=EC=A6=9D=20=EC=98=B5=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubApplicationFormCreateRequest.java | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java index 3900e4800..3246ef46a 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java @@ -1,22 +1,19 @@ package moadong.club.payload.request; import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.*; + import java.util.List; import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.SemesterTerm; +import org.springframework.util.StringUtils; public record ClubApplicationFormCreateRequest( @NotBlank @Size(max = 50) String title, - @NotBlank @Size(max = 3000) String description, @@ -36,4 +33,25 @@ public record ClubApplicationFormCreateRequest( @NotNull SemesterTerm semesterTerm ) { + + @AssertTrue(message = "지원서 양식에 필요한 필드가 누락되었습니다.") + private boolean isInternalFormValid() { + if (formMode != ApplicationFormMode.INTERNAL) { + return true; + } + + boolean hasDescription = StringUtils.hasText(description); + boolean hasQuestions = questions != null && !questions.isEmpty(); + + return hasDescription && hasQuestions; + } + + @AssertTrue(message = "외부 링크가 누락되었습니다.") + private boolean isExternalFormValid() { + if (formMode != ApplicationFormMode.EXTERNAL) { + return true; + } + + return StringUtils.hasText(externalApplicationUrl); + } } From 7019556acc629a08b6286b423392ea88414ce616 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 23 Nov 2025 00:41:09 +0900 Subject: [PATCH 31/69] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=9D=91=EB=8B=B5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EC=A1=B4=EC=9E=AC=EC=8B=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/ClubApplicationFormCreateRequest.java | 5 ++--- .../moadong/club/service/ClubApplyAdminService.java | 10 +++++++--- .../main/java/moadong/global/exception/ErrorCode.java | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java index 3246ef46a..67094545f 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java @@ -2,13 +2,12 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.*; - -import java.util.List; - import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.SemesterTerm; import org.springframework.util.StringUtils; +import java.util.List; + public record ClubApplicationFormCreateRequest( @NotBlank @Size(max = 50) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index bd296ae16..c79b5f120 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -82,7 +82,6 @@ private void validateSemester(Integer semesterYear, SemesterTerm semesterTerm) { } public void createClubApplicationForm(CustomUserDetails user, ClubApplicationFormCreateRequest request) { - if (request.questions() == null || request.externalApplicationUrl() == null) throw new RestApiException(ErrorCode.APPLICATION_OPTIONS_MISSING); validateSemester(request.semesterYear(), request.semesterTerm()); ClubApplicationForm clubApplicationForm = createApplicationForm( @@ -217,13 +216,15 @@ public void deleteApplicant(String applicationFormId, ClubApplicantDeleteRequest } private ClubApplicationForm createApplicationForm(ClubApplicationForm clubApplicationForm, ClubApplicationFormCreateRequest request) { - clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions())); + if (request.questions() != null) + clubApplicationForm.updateQuestions(buildClubFormQuestions(request.questions())); + if (request.externalApplicationUrl() != null) + clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl()); clubApplicationForm.updateFormTitle(request.title()); clubApplicationForm.updateFormDescription(request.description()); clubApplicationForm.updateSemesterYear(request.semesterYear()); clubApplicationForm.updateSemesterTerm(request.semesterTerm()); clubApplicationForm.updateFormMode(request.formMode()); - clubApplicationForm.updateExternalApplicationUrl(request.externalApplicationUrl()); return clubApplicationForm; } @@ -260,6 +261,9 @@ private List buildClubFormQuestions(List items = new ArrayList<>(); + Set distinctQuestionItemList = new HashSet<>(question.items()); + if (distinctQuestionItemList.size() != question.items().size()) throw new RestApiException(ErrorCode.DUPLICATE_QUESTIONS_ITEMS); + for (var item : question.items()) { items.add(ClubQuestionItem.builder() .value(item.value()) diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index e989e8ff2..2d31d706d 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -54,8 +54,8 @@ public enum ErrorCode { REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."), ACTIVE_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-6", "활성화된 지원서 양식이 존재하지 않습니다."), APPLICATION_SEMESTER_INVALID(HttpStatus.BAD_REQUEST, "800-7", "올바르지 않은 학기입니다."), - APPLICATION_OPTIONS_MISSING(HttpStatus.BAD_REQUEST, "800-8", "지원서 생성에 필요한 필드가 누락되었습니다."), - NOT_ALLOWED_EXTERNAL_URL(HttpStatus.BAD_REQUEST, "800-9", "형식에 맞지않은 외부지원서 URL 입니다."), + NOT_ALLOWED_EXTERNAL_URL(HttpStatus.BAD_REQUEST, "800-8", "형식에 맞지않은 외부지원서 URL 입니다."), + DUPLICATE_QUESTIONS_ITEMS(HttpStatus.BAD_REQUEST, "800-9", "중복된 질문 선택지가 존재합니다."), // 900xx: 기타 시스템 오류 AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."), From 9c1c1ab2ec8bf38a1a97cccac0e63c5254649b7f Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 23 Nov 2025 01:25:35 +0900 Subject: [PATCH 32/69] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/moadong/club/entity/ClubApplicationForm.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index e48a56fd2..b51198d96 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -118,7 +118,7 @@ public void updateExternalApplicationUrl(String externalApplicationUrl) { throw new RestApiException(ErrorCode.NOT_ALLOWED_EXTERNAL_URL); } - this.externalApplicationUrl = externalApplicationUrl; + this.externalApplicationUrl = externalApplicationUrl.trim(); } @Override From 042940ade511357e218870c6a6fc2d2826ed4cc0 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Sun, 23 Nov 2025 01:30:24 +0900 Subject: [PATCH 33/69] =?UTF-8?q?fix:=20=ED=98=95=EC=8B=9D=EC=97=90?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/moadong/fixture/ClubApplicationEditFixture.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java b/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java index 9a7d7b9cd..343895117 100644 --- a/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java @@ -1,5 +1,6 @@ package moadong.fixture; +import moadong.club.enums.ApplicationFormMode; import moadong.club.enums.SemesterTerm; import moadong.club.payload.request.ClubApplicationFormEditRequest; import java.util.ArrayList; @@ -20,6 +21,8 @@ public static ClubApplicationFormEditRequest createClubApplicationEditRequest(){ "테스트 지원서입니다", false, new ArrayList<>(), + "", + ApplicationFormMode.INTERNAL, 2025, SemesterTerm.SECOND ); From 6a764cf0b193b7826d7f76c14ac9781698e05dbb Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 26 Nov 2025 15:00:34 +0900 Subject: [PATCH 34/69] =?UTF-8?q?fix:=20X-Accel-Buffering=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20nginx=EC=9D=98=20?= =?UTF-8?q?=EB=B2=84=ED=8D=BC=EB=A7=81=EC=9D=84=20=EC=A0=9C=EC=96=B4?= =?UTF-8?q?=ED=95=B4=20sse=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=88=98=EB=A6=BD=EC=9D=84=20=ED=95=9C=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubApplyAdminController.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java index 44c50f36b..d3784936d 100644 --- a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java +++ b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; @@ -115,13 +116,16 @@ public ResponseEntity removeApplicant(@PathVariable String applicationFormId, return Response.ok("success delete applicant"); } - @GetMapping(value = "/applicant/{applicationFormId}/events",produces = "text/event-stream") - @Operation(summary = "지원자 상태 변경 실시간 이벤트", - description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.") + @GetMapping(value = "/applicant/{applicationFormId}/events", produces = "text/event-stream") + @Operation(summary = "지원자 상태 변경 실시간 이벤트", + description = "지원자의 상태 변경을 실시간으로 받아볼 수 있는 SSE 엔드포인트입니다.") @PreAuthorize("isAuthenticated()") @SecurityRequirement(name = "BearerAuth") - public SseEmitter getApplicantStatusEvents(@PathVariable String applicationFormId, + public SseEmitter getApplicantStatusEvents(HttpServletResponse response, + @PathVariable String applicationFormId, @CurrentUser CustomUserDetails user) { + response.addHeader("X-Accel-Buffering", "no"); + response.addHeader("Cache-Control", "no-cache"); return clubApplyAdminService.createSseConnection(applicationFormId, user); } From 6a256aac23bc2749e95bc32387a622862703fcf9 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 26 Nov 2025 22:19:27 +0900 Subject: [PATCH 35/69] =?UTF-8?q?feature:=20=EB=AA=A8=EC=A7=91=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=A0=EC=A7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 9 ++++++--- .../moadong/club/payload/dto/ClubDetailedResult.java | 7 +++++++ .../java/moadong/club/service/ClubProfileService.java | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index c9df6fd1f..3c2cf9f0f 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -1,17 +1,16 @@ package moadong.club.entity; -import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; import java.util.List; -import java.util.Locale; import java.util.Map; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; @@ -52,6 +51,10 @@ public class Club implements Persistable { @Version private Long version; + @Setter + @Getter + private LocalDateTime lastModified; + public Club() { this.name = ""; this.category = ""; diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 37802ac40..af3396c44 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -29,6 +29,7 @@ public record ClubDetailedResult( String category, String division, List faqs, + String lastModified, List recommendClubs ) { @@ -40,6 +41,11 @@ public static ClubDetailedResult of(Club club, List recommendC period = clubRecruitmentInformation.getRecruitmentStart().format(formatter) + " ~ " + clubRecruitmentInformation.getRecruitmentEnd().format(formatter); } + String lastModified = ""; + if (club.getLastModified() != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"); + lastModified = club.getLastModified().format(formatter); + } return ClubDetailedResult.builder() .id(club.getId() == null ? "" : club.getId()) .name(club.getName() == null ? "" : club.getName()) @@ -74,6 +80,7 @@ public static ClubDetailedResult of(Club club, List recommendC : club.getSocialLinks()) .faqs(club.getClubRecruitmentInformation().getFaqs() == null ? List.of() : club.getClubRecruitmentInformation().getFaqs()) + .lastModified(lastModified) .recommendClubs(recommendClubs) .build(); } diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 4759a5566..63d7d7c43 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -1,5 +1,6 @@ package moadong.club.service; +import java.time.LocalDateTime; import java.util.List; import lombok.AllArgsConstructor; import moadong.club.entity.Club; @@ -44,6 +45,7 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, club.getClubRecruitmentInformation().getRecruitmentStart(), club.getClubRecruitmentInformation().getRecruitmentEnd() ); + club.setLastModified(LocalDateTime.now()); clubRepository.save(club); } From 373ffeb195ac0515e2fe081dbf03b2cc3ca4736b Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 26 Nov 2025 22:21:56 +0900 Subject: [PATCH 36/69] =?UTF-8?q?test:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=82=A0=EC=A7=9C=20=EA=B5=AC=ED=98=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ClubProfileServiceDateTest.java | 70 +++++++++++++++++++ .../moadong/fixture/ClubRequestFixture.java | 13 ++++ 2 files changed, 83 insertions(+) create mode 100644 backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java new file mode 100644 index 000000000..8af35b4c0 --- /dev/null +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -0,0 +1,70 @@ +package moadong.club.service; + +import moadong.club.entity.Club; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; +import moadong.club.repository.ClubRepository; +import moadong.club.util.RecruitmentStateCalculator; +import moadong.fixture.ClubRequestFixture; +import moadong.fixture.UserFixture; +import moadong.user.payload.CustomUserDetails; +import moadong.util.annotations.UnitTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + + +import java.time.LocalDateTime; +import java.util.Optional; + + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@UnitTest +public class ClubProfileServiceDateTest { + + @Spy + @InjectMocks + ClubProfileService clubProfileService; + + @Mock + ClubRepository clubRepository; + + @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") + @Test + void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ + //GIVEN + ClubRecruitmentInfoUpdateRequest request = ClubRequestFixture.defaultRequest(); + CustomUserDetails customUserDetails = UserFixture.createUserDetails("test"); + Club club = new Club(); + when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(club)); + //updateClubRecruitmentInfo의 RecruitmentStateCalculator 무시 + try (var mocked = Mockito.mockStatic(RecruitmentStateCalculator.class)) { + mocked.when(() -> + RecruitmentStateCalculator.calculate( + Mockito.any(moadong.club.entity.Club.class), + Mockito.any(java.time.ZonedDateTime.class), + Mockito.any(java.time.ZonedDateTime.class) + ) + ).thenAnswer(inv -> null); + + //WHEN + clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); + + //THEN + assertNotNull(club.getLastModified()); + //1초 전후 차이로 살펴보기 + LocalDateTime now = LocalDateTime.now(); + assertTrue(club.getLastModified().isAfter(now.minusSeconds(1))); + assertTrue(club.getLastModified().isBefore(now.plusSeconds(1))); + } + } +} diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java index 8d5feef1a..9021fb550 100644 --- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -3,7 +3,9 @@ import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -21,6 +23,17 @@ public static ClubInfoRequest createValidClubInfoRequest() { ); } + + public static ClubRecruitmentInfoUpdateRequest defaultRequest() { + return new ClubRecruitmentInfoUpdateRequest( + LocalDateTime.now(), + LocalDateTime.now().plusDays(7), + "테스트 대상", + "테스트 설명", + "https://fake-url.com", + List.of() + ); + } //ToDo: 시간 계산법을 LocalDateTime에서 Instant로 변경 후에 활성화할 것 // public static ClubRecruitmentInfoUpdateRequest createValidRequest() { // return new ClubRecruitmentInfoUpdateRequest( From 8bec173bd685e92eb8a0721fb12ee4fa55c4cfbc Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 26 Nov 2025 22:45:03 +0900 Subject: [PATCH 37/69] =?UTF-8?q?fix:=20@Getter=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 3c2cf9f0f..90ab90022 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -52,7 +52,6 @@ public class Club implements Persistable { private Long version; @Setter - @Getter private LocalDateTime lastModified; public Club() { From 47de23d56b6a7ac08abcefaeac9d257ec3d778da Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 26 Nov 2025 22:50:16 +0900 Subject: [PATCH 38/69] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/club/service/ClubProfileServiceDateTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index 8af35b4c0..6bd6d5077 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -3,6 +3,7 @@ import moadong.club.entity.Club; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.repository.ClubRepository; +import moadong.club.repository.ClubSearchRepository; import moadong.club.util.RecruitmentStateCalculator; import moadong.fixture.ClubRequestFixture; import moadong.fixture.UserFixture; @@ -31,13 +32,15 @@ @UnitTest public class ClubProfileServiceDateTest { - @Spy @InjectMocks ClubProfileService clubProfileService; @Mock ClubRepository clubRepository; + @Mock + ClubSearchRepository clubSearchRepository; + @DisplayName("모집글 수정 시 최근 업데이트 일자를 보여준다") @Test void 모집글_수정_시_최근_업데이트_일자를_보여줘야한다(){ From a6be5387be0f7165e0e4202ff68cf77b0c111114 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Sun, 30 Nov 2025 17:43:43 +0900 Subject: [PATCH 39/69] =?UTF-8?q?fix:=20=EB=82=A0=EC=A7=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 6 ------ .../club/entity/ClubRecruitmentInformation.java | 11 +++++++++++ .../moadong/club/payload/dto/ClubDetailedResult.java | 11 ++++++----- .../java/moadong/club/service/ClubProfileService.java | 2 +- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 90ab90022..df0f6e146 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -1,6 +1,5 @@ package moadong.club.entity; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -10,7 +9,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; @@ -50,10 +48,6 @@ public class Club implements Persistable { @Version private Long version; - - @Setter - private LocalDateTime lastModified; - public Club() { this.name = ""; this.category = ""; diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index 9de02b81b..777144393 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -13,6 +13,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.Setter; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; @@ -66,6 +67,8 @@ public class ClubRecruitmentInformation { @NotNull private ClubRecruitmentStatus clubRecruitmentStatus; + private LocalDateTime lastModifiedDate; + public void updateLogo(String logo) { this.logo = logo; } @@ -121,4 +124,12 @@ public void update(ClubInfoRequest request) { public void updateCover(String cover) { this.cover = cover; } + + private void setLastModifiedDate(LocalDateTime lastModifiedDate) { + this.lastModifiedDate = lastModifiedDate; + } + + public void updateLastModifiedDate() { + setLastModifiedDate(LocalDateTime.now()); + } } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index af3396c44..58e224e12 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -29,7 +29,7 @@ public record ClubDetailedResult( String category, String division, List faqs, - String lastModified, + String lastModifiedDate, List recommendClubs ) { @@ -41,10 +41,11 @@ public static ClubDetailedResult of(Club club, List recommendC period = clubRecruitmentInformation.getRecruitmentStart().format(formatter) + " ~ " + clubRecruitmentInformation.getRecruitmentEnd().format(formatter); } - String lastModified = ""; - if (club.getLastModified() != null) { + + String lastModifiedDate = ""; + if (club.getClubRecruitmentInformation().getLastModifiedDate() != null) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"); - lastModified = club.getLastModified().format(formatter); + lastModifiedDate = club.getClubRecruitmentInformation().getLastModifiedDate().format(formatter); } return ClubDetailedResult.builder() .id(club.getId() == null ? "" : club.getId()) @@ -80,7 +81,7 @@ public static ClubDetailedResult of(Club club, List recommendC : club.getSocialLinks()) .faqs(club.getClubRecruitmentInformation().getFaqs() == null ? List.of() : club.getClubRecruitmentInformation().getFaqs()) - .lastModified(lastModified) + .lastModifiedDate(lastModifiedDate) .recommendClubs(recommendClubs) .build(); } diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 63d7d7c43..4f8c6c6ca 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -45,7 +45,7 @@ public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, club.getClubRecruitmentInformation().getRecruitmentStart(), club.getClubRecruitmentInformation().getRecruitmentEnd() ); - club.setLastModified(LocalDateTime.now()); + club.getClubRecruitmentInformation().updateLastModifiedDate(); clubRepository.save(club); } From 8898972ff3aa5eb06083aad22fec60564db0a834 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Sun, 30 Nov 2025 18:41:22 +0900 Subject: [PATCH 40/69] =?UTF-8?q?fix:=20test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/club/service/ClubProfileServiceDateTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java index 6bd6d5077..5650b77bd 100644 --- a/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java +++ b/backend/src/test/java/moadong/club/service/ClubProfileServiceDateTest.java @@ -63,11 +63,13 @@ public class ClubProfileServiceDateTest { clubProfileService.updateClubRecruitmentInfo(request, customUserDetails); //THEN - assertNotNull(club.getLastModified()); + assertNotNull(club.getClubRecruitmentInformation().getLastModifiedDate()); //1초 전후 차이로 살펴보기 LocalDateTime now = LocalDateTime.now(); - assertTrue(club.getLastModified().isAfter(now.minusSeconds(1))); - assertTrue(club.getLastModified().isBefore(now.plusSeconds(1))); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isAfter(now.minusSeconds(1))); + assertTrue(club.getClubRecruitmentInformation(). + getLastModifiedDate().isBefore(now.plusSeconds(1))); } } } From 7473305d6d1e184f7af3bfb57f9b07550cb30ebb Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 3 Dec 2025 15:38:34 +0900 Subject: [PATCH 41/69] =?UTF-8?q?test:=20=EA=B0=AF=EC=88=98=20test=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...flareImageServiceUpdateFeedsLimitTest.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java diff --git a/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java new file mode 100644 index 000000000..b5839b372 --- /dev/null +++ b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java @@ -0,0 +1,78 @@ +package moadong.media.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.util.annotations.UnitTest; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@ExtendWith(MockitoExtension.class) +@UnitTest +class CloudflareImageServiceUpdateFeedsLimitTest { + + @Spy + @InjectMocks + private CloudflareImageService cloudflareImageService; + + @Mock + private ClubRepository clubRepository; + + @Mock + private S3Client s3Client; + + @Mock + private S3Presigner s3Presigner; + + private final int MAX_FEED_COUNT = 15; + + private Club club; + private ObjectId objectId; + + @BeforeEach + void setUp() { + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .feedImages(List.of()) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + objectId = new ObjectId(); + } + + @Test + void MAX_FEED_COUNT_이상의_피드를_업로드하면_TOO_MANY_FILES를_반환한다() { + // given + List tooMany = Arrays.asList(new String[MAX_FEED_COUNT]); + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .feedImages(tooMany) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); + + // when + RestApiException exception = assertThrows(RestApiException.class, + () -> cloudflareImageService.updateFeeds(objectId.toHexString(), tooMany)); + + // then + assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); + } +} + + From 11cd9c6f1fc9afd361b3c7c96a94fcaa0fae50d3 Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 3 Dec 2025 15:39:24 +0900 Subject: [PATCH 42/69] =?UTF-8?q?test:=20deleteFile=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CloudFlareImageServiceTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java diff --git a/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java b/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java new file mode 100644 index 000000000..3fe1b1e9f --- /dev/null +++ b/backend/src/test/java/moadong/media/service/CloudFlareImageServiceTest.java @@ -0,0 +1,116 @@ +package moadong.media.service; + +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.util.annotations.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@UnitTest +@ExtendWith(MockitoExtension.class) +public class CloudFlareImageServiceTest { + + @Spy + @InjectMocks + CloudflareImageService cloudflareImageService; + + @Mock + private ClubRepository clubRepository; + + @Mock + private S3Client s3Client; + + @Mock + private S3Presigner s3Presigner; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(cloudflareImageService, "viewEndpoint", "https://cdn.example.com/"); + ReflectionTestUtils.setField(cloudflareImageService, "bucketName", "test-bucket"); + ReflectionTestUtils.invokeMethod(cloudflareImageService, "init"); + } + + + @Test + void 잘못된_이름을_넣으면_UNSUPPORTED_FILE_TYPE_을_반환한다(){ + //given + String fileName = "Hello"; + //when + RestApiException exception = assertThrows(RestApiException.class, + () -> ReflectionTestUtils.invokeMethod(cloudflareImageService, "validateFileName", fileName)); + //then + assertEquals(ErrorCode.UNSUPPORTED_FILE_TYPE, exception.getErrorCode()); + } + + @Test + void File_이름이_비어있으면_FILE_NOT_FOUND를_반환한다() { + //given + //이름이 비어있는 파일 + String emptyFileName = ""; + //when + //이름이 비어있는 파일을 넣었을 때 + RestApiException exception = assertThrows(RestApiException.class, + () -> ReflectionTestUtils.invokeMethod(cloudflareImageService, "validateFileName", emptyFileName)); + //then + //에러코드가 같아야함 + assertEquals(ErrorCode.FILE_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 잘못된_filePath_일때_deleteFile_을_실패한다 (){ + //given + Club club = new Club(); + String filePath = null; + //when + cloudflareImageService.deleteFile(club, filePath); + //then + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + } + + @Test + void 접두_불일치면_deleteFile_을_실패한다 (){ + //given + Club club = new Club(); + String wrongFilePath ="https://other.example.com/club123/logo/abc.png"; + + //when + cloudflareImageService.deleteFile(club, wrongFilePath); + //then + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + } + + @Test + void 정상_url이면_삭제_성공 () { + //given + Club club = new Club(); + String filePath = "https://cdn.example.com/club123/logo/abc.png"; + + //when + cloudflareImageService.deleteFile(club, filePath); + + //then + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(captor.capture()); + + assertEquals("test-bucket", captor.getValue().bucket()); + assertEquals("club123/logo/abc.png", captor.getValue().key()); + } +} From 97a7e69e48954a993aa862c75b48020bbe5093d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:53:49 +0900 Subject: [PATCH 43/69] =?UTF-8?q?refactor:=20instant=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클럽 모집정보 requestDTO와 엔티티를 localDateTime에서 변경함 --- .../moadong/club/entity/ClubRecruitmentInformation.java | 6 ++++-- .../payload/request/ClubRecruitmentInfoUpdateRequest.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index 777144393..59eafb0ff 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -6,6 +6,8 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; + +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -49,9 +51,9 @@ public class ClubRecruitmentInformation { @Column(length = 13) private String presidentTelephoneNumber; - private LocalDateTime recruitmentStart; + private Instant recruitmentStart; - private LocalDateTime recruitmentEnd; + private Instant recruitmentEnd; private String recruitmentTarget; diff --git a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java index 34ad95521..3c610ed0a 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java @@ -1,12 +1,13 @@ package moadong.club.payload.request; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import moadong.club.entity.Faq; public record ClubRecruitmentInfoUpdateRequest( - LocalDateTime recruitmentStart, - LocalDateTime recruitmentEnd, + Instant recruitmentStart, + Instant recruitmentEnd, String recruitmentTarget, String description, String externalApplicationUrl, From 87fb0d42fd8a6b5c7a60c72d091a27232eeef2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:16:47 +0900 Subject: [PATCH 44/69] =?UTF-8?q?refactor:=20=EB=AA=A8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20start/end=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/payload/dto/ClubDetailedResult.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 58e224e12..378dfbc46 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -21,7 +21,8 @@ public record ClubDetailedResult( String description, String presidentName, String presidentPhoneNumber, - String recruitmentPeriod, + String recruitmentStart, + String recruitmentEnd, String recruitmentTarget, String recruitmentStatus, String externalApplicationUrl, @@ -34,12 +35,14 @@ public record ClubDetailedResult( ) { public static ClubDetailedResult of(Club club, List recommendClubs) { - String period = "미정"; ClubRecruitmentInformation clubRecruitmentInformation = club.getClubRecruitmentInformation(); + + String start = "미정"; + String end = "미정"; if (clubRecruitmentInformation.hasRecruitmentPeriod()) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"); - period = clubRecruitmentInformation.getRecruitmentStart().format(formatter) + " ~ " - + clubRecruitmentInformation.getRecruitmentEnd().format(formatter); + start = clubRecruitmentInformation.getRecruitmentStart().format(formatter); + end = clubRecruitmentInformation.getRecruitmentEnd().format(formatter); } String lastModifiedDate = ""; @@ -70,7 +73,8 @@ public static ClubDetailedResult of(Club club, List recommendC .presidentPhoneNumber( clubRecruitmentInformation.getPresidentTelephoneNumber() == null ? "" : clubRecruitmentInformation.getPresidentTelephoneNumber()) - .recruitmentPeriod(period) + .recruitmentStart(start) + .recruitmentEnd(end) .recruitmentTarget(clubRecruitmentInformation.getRecruitmentTarget() == null ? "" : clubRecruitmentInformation.getRecruitmentTarget()) .recruitmentStatus(clubRecruitmentInformation.getClubRecruitmentStatus() == null From 7cdd077f81f63309e61a53ca9825c631cb08e9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:29:44 +0900 Subject: [PATCH 45/69] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EB=8F=84=20Instant=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/moadong/fixture/ClubRequestFixture.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java index 9021fb550..7d830f384 100644 --- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -5,7 +5,9 @@ import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; @@ -26,8 +28,8 @@ public static ClubInfoRequest createValidClubInfoRequest() { public static ClubRecruitmentInfoUpdateRequest defaultRequest() { return new ClubRecruitmentInfoUpdateRequest( - LocalDateTime.now(), - LocalDateTime.now().plusDays(7), + Instant.now(), + Instant.now().plus(7, ChronoUnit.DAYS), "테스트 대상", "테스트 설명", "https://fake-url.com", From 1cf8768f9571b4b24de8d99de558b5295ed447f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:57:07 +0900 Subject: [PATCH 46/69] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=A4=91=EB=B3=B5=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/moadong/fcm/repository/FcmTokenRepository.java | 1 + backend/src/test/java/moadong/fcm/service/FcmServiceTest.java | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java index 3a10ff83c..f14e6ee87 100644 --- a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java +++ b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java @@ -7,4 +7,5 @@ public interface FcmTokenRepository extends MongoRepository { Optional findFcmTokenByToken(String fcmToken); + void deleteFcmTokenByToken(String fcmToken); } diff --git a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java index d92d380e8..bb90289cf 100644 --- a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java +++ b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java @@ -66,6 +66,7 @@ public TaskExecutor taskExecutor() { @BeforeEach void setUp() { + fcmTokenRepository.deleteFcmTokenByToken("existing_token"); club1 = clubRepository.save(Club.builder().name("club1").build()); club2 = clubRepository.save(Club.builder().name("club2").build()); club3 = clubRepository.save(Club.builder().name("club3").build()); From 806f92dd59a43c24250296e8fb9ed67661b093d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:00:44 +0900 Subject: [PATCH 47/69] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=A4=91=EB=B3=B5=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/fcm/repository/FcmTokenRepository.java | 2 ++ backend/src/test/java/moadong/fcm/service/FcmServiceTest.java | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java index 3a10ff83c..5ec2e7c2d 100644 --- a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java +++ b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java @@ -7,4 +7,6 @@ public interface FcmTokenRepository extends MongoRepository { Optional findFcmTokenByToken(String fcmToken); + void deleteFcmTokenByToken(String fcmToken); + } diff --git a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java index d92d380e8..bb90289cf 100644 --- a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java +++ b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java @@ -66,6 +66,7 @@ public TaskExecutor taskExecutor() { @BeforeEach void setUp() { + fcmTokenRepository.deleteFcmTokenByToken("existing_token"); club1 = clubRepository.save(Club.builder().name("club1").build()); club2 = clubRepository.save(Club.builder().name("club2").build()); club3 = clubRepository.save(Club.builder().name("club3").build()); From 138f53ecd0dda32bd0e53c60edf150e94f3ddb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:26:49 +0900 Subject: [PATCH 48/69] =?UTF-8?q?fix:=20=ED=8A=B9=EC=88=98=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=20=EA=B2=80=EC=83=89=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubSearchController.java | 2 +- .../moadong/club/service/ClubSearchService.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/controller/ClubSearchController.java b/backend/src/main/java/moadong/club/controller/ClubSearchController.java index 4a9c6a428..3ac3255d9 100644 --- a/backend/src/main/java/moadong/club/controller/ClubSearchController.java +++ b/backend/src/main/java/moadong/club/controller/ClubSearchController.java @@ -29,7 +29,7 @@ public class ClubSearchController { + "keyword에 빈칸 입력 시 전체 검색
" + "recruitmentStatus, category, division에 all 입력 시 전체 검색
" + "
" - + "keyword는 대소문자 구분 없이 자유롭게 검색" + + "keyword는 대소문자 구분 없이 자유롭게 검색
" + "recruitmentStatus은 모집상태로 ALWAYS(상시모집), OPEN(모집중), CLOSED(모집마감), UPCOMING(모집예정)
" + "division은 분과로 중동
" + "category는 종류로 봉사, 종교, 취미교양, 학술, 운동, 공연, 기타
") diff --git a/backend/src/main/java/moadong/club/service/ClubSearchService.java b/backend/src/main/java/moadong/club/service/ClubSearchService.java index 1986c0365..ae67e0bd9 100644 --- a/backend/src/main/java/moadong/club/service/ClubSearchService.java +++ b/backend/src/main/java/moadong/club/service/ClubSearchService.java @@ -23,8 +23,10 @@ public ClubSearchResponse searchClubsByKeyword(String keyword, String division, String category ) { + String normalizedKeyword = normalizeKeyword(keyword); + List result = clubSearchRepository.searchClubsByKeyword( - keyword, + normalizedKeyword, recruitmentStatus, division, category @@ -60,4 +62,13 @@ public ClubSearchResponse searchClubsByKeyword(String keyword, .totalCount(result.size()) .build(); } + + private String normalizeKeyword(String keyword) { + if (keyword == null) return null; + + String trimmedKeyword = keyword.trim(); + if (trimmedKeyword.isEmpty()) return ""; + + return trimmedKeyword.replaceAll("[^0-9A-Za-z가-힣\\s]", "").trim(); + } } From 5a0157f1f734716608e00a442a24fe892356eb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:59:22 +0900 Subject: [PATCH 49/69] =?UTF-8?q?refactor:=20=ED=8A=B9=EC=88=98=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EA=B1=B0=20=EC=BD=94=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=9B=84=20=EC=A0=95=EA=B7=9C=EC=8B=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moadong/club/service/ClubSearchService.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/moadong/club/service/ClubSearchService.java b/backend/src/main/java/moadong/club/service/ClubSearchService.java index ae67e0bd9..e6fc909dc 100644 --- a/backend/src/main/java/moadong/club/service/ClubSearchService.java +++ b/backend/src/main/java/moadong/club/service/ClubSearchService.java @@ -1,6 +1,7 @@ package moadong.club.service; import java.util.*; +import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.AllArgsConstructor; import moadong.club.enums.ClubCategory; @@ -23,10 +24,10 @@ public ClubSearchResponse searchClubsByKeyword(String keyword, String division, String category ) { - String normalizedKeyword = normalizeKeyword(keyword); + String quotedKeyword = quotedKeyword(keyword); List result = clubSearchRepository.searchClubsByKeyword( - normalizedKeyword, + quotedKeyword, recruitmentStatus, division, category @@ -63,12 +64,7 @@ public ClubSearchResponse searchClubsByKeyword(String keyword, .build(); } - private String normalizeKeyword(String keyword) { - if (keyword == null) return null; - - String trimmedKeyword = keyword.trim(); - if (trimmedKeyword.isEmpty()) return ""; - - return trimmedKeyword.replaceAll("[^0-9A-Za-z가-힣\\s]", "").trim(); + private String quotedKeyword(String keyword) { + return (keyword == null || keyword.trim().isEmpty()) ? keyword : Pattern.quote(keyword.trim()); } } From a3271ca8303f153b627e332c7a7df8977073f280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:10:18 +0900 Subject: [PATCH 50/69] =?UTF-8?q?test:=20=EA=B2=80=EC=83=89=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=20keyword=20=ED=98=B8=EC=B6=9C=20=EC=9D=B8=EC=9E=90?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/moadong/club/service/ClubSearchServiceTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java index 93e29f0ee..cecf1e126 100644 --- a/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java +++ b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java @@ -6,6 +6,8 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.regex.Pattern; + import moadong.club.payload.dto.ClubSearchResult; import moadong.club.payload.response.ClubSearchResponse; import moadong.club.repository.ClubSearchRepository; @@ -43,7 +45,7 @@ class ClubSearchServiceTest { List unsorted = List.of(club1, club2, club3,club4); - when(clubSearchRepository.searchClubsByKeyword(keyword, recruitmentStatus, division, category)) + when(clubSearchRepository.searchClubsByKeyword(Pattern.quote(keyword), recruitmentStatus, division, category)) .thenReturn(unsorted); // when From 9a4ec5dcdf99b0ad9d99dc534335028ee96da847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=95=84?= <143075401+alsdddk@users.noreply.github.com> Date: Sat, 13 Dec 2025 22:35:25 +0900 Subject: [PATCH 51/69] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=EC=97=90=EC=84=9C=20=EC=86=8C=EA=B0=9C?= =?UTF-8?q?=EA=B8=80,=20=EC=83=81=EC=84=B8=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B6=84=EA=B3=BC=ED=83=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/club/repository/ClubSearchRepository.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java index 64c5580ca..5a0728b1a 100644 --- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java @@ -43,8 +43,7 @@ public List searchClubsByKeyword(String keyword, String recrui if (keyword != null && !keyword.trim().isEmpty()) { operations.add(Aggregation.match(new Criteria().orOperator( Criteria.where("name").regex(keyword, "i"), - Criteria.where("recruitmentInformation.introduction").regex(keyword, "i"), - Criteria.where("recruitmentInformation.description").regex(keyword, "i"), + Criteria.where("category").regex(keyword, "i"), Criteria.where("recruitmentInformation.tags").regex(keyword, "i") ))); } From f91d0852a511b10b85efb7646d1e9d0d3e3ebff6 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 19:13:25 +0900 Subject: [PATCH 52/69] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=9D=91=EB=8B=B5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/payload/dto/ClubDetailedResult.java | 6 ++-- .../club/repository/ClubSearchRepository.java | 35 ------------------- .../club/service/ClubProfileService.java | 11 ++---- 3 files changed, 5 insertions(+), 47 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 378dfbc46..3c29ba1a0 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -30,11 +30,10 @@ public record ClubDetailedResult( String category, String division, List faqs, - String lastModifiedDate, - List recommendClubs + String lastModifiedDate ) { - public static ClubDetailedResult of(Club club, List recommendClubs) { + public static ClubDetailedResult of(Club club) { ClubRecruitmentInformation clubRecruitmentInformation = club.getClubRecruitmentInformation(); String start = "미정"; @@ -86,7 +85,6 @@ public static ClubDetailedResult of(Club club, List recommendC .faqs(club.getClubRecruitmentInformation().getFaqs() == null ? List.of() : club.getClubRecruitmentInformation().getFaqs()) .lastModifiedDate(lastModifiedDate) - .recommendClubs(recommendClubs) .build(); } diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java index 5a0728b1a..860bdbea2 100644 --- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java @@ -67,41 +67,6 @@ public List searchClubsByKeyword(String keyword, String recrui return results.getMappedResults(); } - public List searchRecommendClubs(String category, String excludeClubId) { - Set excludeIds = new HashSet<>(); - if (excludeClubId != null) { - excludeIds.add(excludeClubId); - } - - List result = new ArrayList<>(); - - // 1. 같은 카테고리 모집중 + (모집마감 포함) 동아리 최대 4개 추출 (모집상태 우선) - int maxCategoryCount = 4; - List categoryClubs = findClubsByCategoryAndState(category, excludeIds, true, maxCategoryCount); - addClubs(result, excludeIds, categoryClubs); - - int remainCount = maxCategoryCount - categoryClubs.size(); - - // 2. 부족하면 마감 동아리로 채우기 - if (remainCount > 0) { - List categoryClosedClubs = findClubsByCategoryAndState(category, excludeIds, false, remainCount); - addClubs(result, excludeIds, categoryClosedClubs); - } - - // 3. 나머지 전체 랜덤 2개(모집상태 우선)로 채우기 - int totalNeeded = 6; - int randomNeeded = totalNeeded - result.size(); - - if (randomNeeded > 0) { - List randomPool = findRandomClubs(excludeIds, 10); - - List selectedRandomClubs = selectClubsByStatePriority(randomPool, randomNeeded); - addClubs(result, excludeIds, selectedRandomClubs); - } - - return result.isEmpty() ? Collections.emptyList() : result; - } - // 같은 카테고리 & 주어진 모집 상태별 랜덤 n개 동아리 조회 private List findClubsByCategoryAndState(String category, Set excludeIds, boolean onlyRecruitAvailable, int limit) { diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 4f8c6c6ca..5116ecba9 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -1,11 +1,8 @@ package moadong.club.service; -import java.time.LocalDateTime; -import java.util.List; import lombok.AllArgsConstructor; import moadong.club.entity.Club; import moadong.club.payload.dto.ClubDetailedResult; -import moadong.club.payload.dto.ClubSearchResult; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.response.ClubDetailedResponse; @@ -36,9 +33,9 @@ public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { } public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, - CustomUserDetails user) { + CustomUserDetails user) { Club club = clubRepository.findClubByUserId(user.getId()) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); club.update(request); RecruitmentStateCalculator.calculate( club, @@ -54,10 +51,8 @@ public ClubDetailedResponse getClubDetail(String clubId) { Club club = clubRepository.findClubById(objectId) .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); - List clubSearchResults = clubSearchRepository.searchRecommendClubs(club.getCategory(), clubId); - ClubDetailedResult clubDetailedResult = ClubDetailedResult.of( - club,clubSearchResults + club ); return new ClubDetailedResponse(clubDetailedResult); } From 13c2822164fb4e079e0291a5bb1bd727bf6cc300 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 19:22:37 +0900 Subject: [PATCH 53/69] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=9D=91=EB=8B=B5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/repository/ClubSearchRepository.java | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java index 860bdbea2..591c817c1 100644 --- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java @@ -67,105 +67,6 @@ public List searchClubsByKeyword(String keyword, String recrui return results.getMappedResults(); } - // 같은 카테고리 & 주어진 모집 상태별 랜덤 n개 동아리 조회 - private List findClubsByCategoryAndState(String category, Set excludeIds, - boolean onlyRecruitAvailable, int limit) { - List ops = new ArrayList<>(); - - Criteria criteria = Criteria.where("category").is(category) - .and("_id").nin(excludeIds); - - if (onlyRecruitAvailable) { - criteria = criteria.and("recruitmentInformation.clubRecruitmentStatus") - .in( - ClubRecruitmentStatus.ALWAYS.toString(), - ClubRecruitmentStatus.OPEN.toString() - ); - } - - ops.add(Aggregation.match(criteria)); - ops.add(Aggregation.sample((long) limit)); - - // searchClubsByKeyword 와 동일한 project 단계 적용 - ops.add( - Aggregation.project("name", "state", "category", "division") - .and("recruitmentInformation.introduction").as("introduction") - .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus") - .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")) - .as("logo") - .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList())) - .as("tags") - ); - - return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class) - .getMappedResults(); - } - - // 중복 ID 추적하며 클럽 리스트에 추가 - private void addClubs(List result, Set excludeIds, List clubs) { - for (ClubSearchResult club : clubs) { - if (!excludeIds.contains(club.id())) { - result.add(club); - excludeIds.add(club.id()); - } - } - } - - // 전체 랜덤 풀에서 모집중 우선으로 n개, 부족하면 마감 동아리로 채움 - private boolean isRecruiting(ClubSearchResult club) { - String status = club.recruitmentStatus(); - return ClubRecruitmentStatus.ALWAYS.toString().equals(status) || ClubRecruitmentStatus.OPEN.toString().equals(status); - } - - private List selectClubsByStatePriority(List pool, int maxCount) { - List selected = new ArrayList<>(); - Set ids = new HashSet<>(); - - // 모집중 우선 선택 - for (ClubSearchResult club : pool) { - if (selected.size() >= maxCount) break; - if (isRecruiting(club) && !ids.contains(club.id())) { - selected.add(club); - ids.add(club.id()); - } - } - - // 부족하면 모집 마감 동아리 추가 - if (selected.size() < maxCount) { - for (ClubSearchResult club : pool) { - if (selected.size() >= maxCount) break; - if (!isRecruiting(club) && !ids.contains(club.id())) { - selected.add(club); - ids.add(club.id()); - } - } - } - - return selected; - } - - // 전체 클럽에서 랜덤 n개 뽑기 (중복 제거용 excludeIds는 외부에서 처리) - private List findRandomClubs(Set excludeIds, int sampleSize) { - List ops = new ArrayList<>(); - ops.add(Aggregation.match(Criteria.where("_id").nin(excludeIds))); - ops.add(Aggregation.sample((long) sampleSize)); - - ops.add( - Aggregation.project("name", "state", "category", "division") - .and("recruitmentInformation.introduction").as("introduction") - .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus") - .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")) - .as("logo") - .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then(Collections.emptyList())) - .as("tags") - ); - - return mongoTemplate.aggregate(Aggregation.newAggregation(ops), "clubs", ClubSearchResult.class) - .getMappedResults(); - } - - - private Criteria getMatchedCriteria(String recruitmentStatus, String division, String category) { List criteriaList = new ArrayList<>(); From b166e76e0b26c0ed374388fec2fbc81dc9fc93b9 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 19:30:15 +0900 Subject: [PATCH 54/69] =?UTF-8?q?refactor:=20faq,=20description=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/Club.java | 10 +++++++--- .../entity/ClubRecruitmentInformation.java | 20 ++++++------------- .../club/payload/dto/ClubDetailedResult.java | 15 +++++++------- .../ClubRecruitmentInfoUpdateRequest.java | 7 +------ 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index df0f6e146..ec732e006 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -1,8 +1,5 @@ package moadong.club.entity; -import java.util.List; -import java.util.Map; - import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; @@ -22,6 +19,9 @@ import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import java.util.List; +import java.util.Map; + @Slf4j @Document("clubs") @AllArgsConstructor @@ -46,6 +46,10 @@ public class Club implements Persistable { @Field("recruitmentInformation") private ClubRecruitmentInformation clubRecruitmentInformation; + private String description; + + private List faqs; + @Version private Long version; public Club() { diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index 59eafb0ff..819df38e2 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -6,22 +6,21 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.Setter; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.global.RegexConstants; import org.checkerframework.common.aliasing.qual.Unique; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + @AllArgsConstructor @Getter @Builder(toBuilder = true) @@ -41,9 +40,6 @@ public class ClubRecruitmentInformation { @Column(length = 30) private String introduction; - @Column(length = 20000) - private String description; - @Column(length = 5) private String presidentName; @@ -63,8 +59,6 @@ public class ClubRecruitmentInformation { private List tags; - private List faqs; - @Enumerated(EnumType.STRING) @NotNull private ClubRecruitmentStatus clubRecruitmentStatus; @@ -80,12 +74,10 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus status) { } public void updateDescription(ClubRecruitmentInfoUpdateRequest request) { - this.description = request.description(); this.recruitmentStart = request.recruitmentStart(); this.recruitmentEnd = request.recruitmentEnd(); this.recruitmentTarget = request.recruitmentTarget(); this.externalApplicationUrl = request.externalApplicationUrl(); - this.faqs = request.faqs(); } public boolean hasRecruitmentPeriod() { diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 3c29ba1a0..b00d995f4 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -1,13 +1,14 @@ package moadong.club.payload.dto; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; import lombok.Builder; import moadong.club.entity.Club; import moadong.club.entity.ClubRecruitmentInformation; import moadong.club.entity.Faq; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + @Builder public record ClubDetailedResult( String id, @@ -65,8 +66,8 @@ public static ClubDetailedResult of(Club club) { .division(club.getDivision() == null ? "" : club.getDivision()) .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" : clubRecruitmentInformation.getIntroduction()) - .description(clubRecruitmentInformation.getDescription() == null ? "" - : clubRecruitmentInformation.getDescription()) + .description(club.getDescription() == null ? "" + : club.getDescription()) .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" : clubRecruitmentInformation.getPresidentName()) .presidentPhoneNumber( @@ -82,8 +83,8 @@ public static ClubDetailedResult of(Club club) { club.getClubRecruitmentInformation().getExternalApplicationUrl()) .socialLinks(club.getSocialLinks() == null ? Map.of() : club.getSocialLinks()) - .faqs(club.getClubRecruitmentInformation().getFaqs() == null ? List.of() - : club.getClubRecruitmentInformation().getFaqs()) + .faqs(club.getFaqs() == null ? List.of() + : club.getFaqs()) .lastModifiedDate(lastModifiedDate) .build(); } diff --git a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java index 3c610ed0a..396d46dd1 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubRecruitmentInfoUpdateRequest.java @@ -1,17 +1,12 @@ package moadong.club.payload.request; import java.time.Instant; -import java.time.LocalDateTime; -import java.util.List; -import moadong.club.entity.Faq; public record ClubRecruitmentInfoUpdateRequest( Instant recruitmentStart, Instant recruitmentEnd, String recruitmentTarget, - String description, - String externalApplicationUrl, - List faqs + String externalApplicationUrl ) { } From 400bece68365d316c338c2ee4390e101a4d454a2 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 19:35:37 +0900 Subject: [PATCH 55/69] =?UTF-8?q?refactor:=20faq,=20description=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 2 ++ .../java/moadong/club/payload/request/ClubInfoRequest.java | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index ec732e006..a53465446 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -98,6 +98,8 @@ public void update(ClubInfoRequest request) { this.state = ClubState.AVAILABLE; this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); + this.description = request.description(); + this.faqs = request.faqs(); } private void validateTags(List tags) { diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java index 0f9c75790..4659311f1 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java @@ -3,6 +3,8 @@ import jakarta.validation.constraints.NotBlank; import java.util.List; import java.util.Map; + +import moadong.club.entity.Faq; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.global.annotation.PhoneNumber; @@ -15,6 +17,8 @@ public record ClubInfoRequest( List tags, String introduction, String presidentName, + String description, + List faqs, @PhoneNumber String presidentPhoneNumber, Map socialLinks From d026c77e0384e9bebea5e0f056694361b8b244f5 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 20:04:56 +0900 Subject: [PATCH 56/69] =?UTF-8?q?feature:=20=EA=B8=B0=EC=A1=B4=20descripti?= =?UTF-8?q?on=20=EB=AC=B8=EC=9E=90=20=ED=98=95=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/entity/Club.java | 2 +- .../java/moadong/club/entity/ClubAward.java | 17 ++++++++++++++++ .../moadong/club/entity/ClubDescription.java | 20 +++++++++++++++++++ .../club/entity/ClubIdealCandidate.java | 17 ++++++++++++++++ .../club/payload/dto/ClubDetailedResult.java | 6 +++--- .../club/payload/request/ClubInfoRequest.java | 3 ++- .../moadong/fixture/ClubRequestFixture.java | 8 +++++--- 7 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/moadong/club/entity/ClubAward.java create mode 100644 backend/src/main/java/moadong/club/entity/ClubDescription.java create mode 100644 backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index a53465446..5d089f5e9 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -46,7 +46,7 @@ public class Club implements Persistable { @Field("recruitmentInformation") private ClubRecruitmentInformation clubRecruitmentInformation; - private String description; + private ClubDescription description; private List faqs; diff --git a/backend/src/main/java/moadong/club/entity/ClubAward.java b/backend/src/main/java/moadong/club/entity/ClubAward.java new file mode 100644 index 000000000..9b3ae019d --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubAward.java @@ -0,0 +1,17 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ClubAward { + private String semester; + private List achievements; +} diff --git a/backend/src/main/java/moadong/club/entity/ClubDescription.java b/backend/src/main/java/moadong/club/entity/ClubDescription.java new file mode 100644 index 000000000..d54bff92c --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubDescription.java @@ -0,0 +1,20 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ClubDescription { + private String introDescription; + private String activityDescription; + private List awards; + private ClubIdealCandidate idealCandidate; + private String benefits; +} diff --git a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java new file mode 100644 index 000000000..8ae3f13ff --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java @@ -0,0 +1,17 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ClubIdealCandidate { + private List tags; + private String content; +} diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index b00d995f4..907ee31bb 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -2,6 +2,7 @@ import lombok.Builder; import moadong.club.entity.Club; +import moadong.club.entity.ClubDescription; import moadong.club.entity.ClubRecruitmentInformation; import moadong.club.entity.Faq; @@ -19,7 +20,7 @@ public record ClubDetailedResult( String state, List feeds, String introduction, - String description, + ClubDescription description, String presidentName, String presidentPhoneNumber, String recruitmentStart, @@ -66,8 +67,7 @@ public static ClubDetailedResult of(Club club) { .division(club.getDivision() == null ? "" : club.getDivision()) .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" : clubRecruitmentInformation.getIntroduction()) - .description(club.getDescription() == null ? "" - : club.getDescription()) + .description(club.getDescription()) .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" : clubRecruitmentInformation.getPresidentName()) .presidentPhoneNumber( diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java index 4659311f1..42a5a23d7 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java @@ -5,6 +5,7 @@ import java.util.Map; import moadong.club.entity.Faq; +import moadong.club.entity.ClubDescription; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.global.annotation.PhoneNumber; @@ -17,7 +18,7 @@ public record ClubInfoRequest( List tags, String introduction, String presidentName, - String description, + ClubDescription description, List faqs, @PhoneNumber String presidentPhoneNumber, diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java index 7d830f384..573b8910a 100644 --- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -1,5 +1,7 @@ package moadong.fixture; +import moadong.club.entity.ClubDescription; +import moadong.club.entity.Faq; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.club.payload.request.ClubInfoRequest; @@ -20,6 +22,8 @@ public static ClubInfoRequest createValidClubInfoRequest() { List.of("개발", "스터디"), "동아리 소개입니다.", "홍길동", + ClubDescription.builder().build(), + List.of(), "010-1234-5678", Map.of("insta", "https://test") ); @@ -31,9 +35,7 @@ public static ClubRecruitmentInfoUpdateRequest defaultRequest() { Instant.now(), Instant.now().plus(7, ChronoUnit.DAYS), "테스트 대상", - "테스트 설명", - "https://fake-url.com", - List.of() + "https://fake-url.com" ); } //ToDo: 시간 계산법을 LocalDateTime에서 Instant로 변경 후에 활성화할 것 From 129e1c9da1937ee08d70c82ebdf3bc5e3d6af278 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 20:15:34 +0900 Subject: [PATCH 57/69] =?UTF-8?q?refactor:=20faqs=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20ClubDescription=20=EC=95=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 3 --- backend/src/main/java/moadong/club/entity/ClubDescription.java | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 5d089f5e9..b8e8ed03d 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -48,8 +48,6 @@ public class Club implements Persistable { private ClubDescription description; - private List faqs; - @Version private Long version; public Club() { @@ -99,7 +97,6 @@ public void update(ClubInfoRequest request) { this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); this.description = request.description(); - this.faqs = request.faqs(); } private void validateTags(List tags) { diff --git a/backend/src/main/java/moadong/club/entity/ClubDescription.java b/backend/src/main/java/moadong/club/entity/ClubDescription.java index d54bff92c..b2265c861 100644 --- a/backend/src/main/java/moadong/club/entity/ClubDescription.java +++ b/backend/src/main/java/moadong/club/entity/ClubDescription.java @@ -17,4 +17,5 @@ public class ClubDescription { private List awards; private ClubIdealCandidate idealCandidate; private String benefits; + private List faqs; } From 8ee92dfda223dbd1207622466399d00ae12ff872 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 20:16:30 +0900 Subject: [PATCH 58/69] =?UTF-8?q?refactor:=20faqs=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/moadong/club/payload/dto/ClubDetailedResult.java | 4 ++-- .../moadong/club/payload/request/ClubInfoRequest.java | 8 +++----- backend/src/test/java/moadong/fixture/ClubFixture.java | 2 -- .../src/test/java/moadong/fixture/ClubRequestFixture.java | 3 --- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 907ee31bb..a5614abe3 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -83,8 +83,8 @@ public static ClubDetailedResult of(Club club) { club.getClubRecruitmentInformation().getExternalApplicationUrl()) .socialLinks(club.getSocialLinks() == null ? Map.of() : club.getSocialLinks()) - .faqs(club.getFaqs() == null ? List.of() - : club.getFaqs()) + .faqs(club.getDescription().getFaqs() == null ? List.of() + : club.getDescription().getFaqs()) .lastModifiedDate(lastModifiedDate) .build(); } diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java index 42a5a23d7..a9f4962da 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java @@ -1,15 +1,14 @@ package moadong.club.payload.request; import jakarta.validation.constraints.NotBlank; -import java.util.List; -import java.util.Map; - -import moadong.club.entity.Faq; import moadong.club.entity.ClubDescription; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.global.annotation.PhoneNumber; +import java.util.List; +import java.util.Map; + public record ClubInfoRequest( @NotBlank String name, @@ -19,7 +18,6 @@ public record ClubInfoRequest( String introduction, String presidentName, ClubDescription description, - List faqs, @PhoneNumber String presidentPhoneNumber, Map socialLinks diff --git a/backend/src/test/java/moadong/fixture/ClubFixture.java b/backend/src/test/java/moadong/fixture/ClubFixture.java index d071b24fd..0049a3bba 100644 --- a/backend/src/test/java/moadong/fixture/ClubFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubFixture.java @@ -23,7 +23,6 @@ public static ClubRecruitmentInformation createRecruitmentInfo( String id, String logo, String introduction, - String description, String presidentName, String presidentTelephoneNumber, LocalDateTime recruitmentStart, @@ -34,7 +33,6 @@ public static ClubRecruitmentInformation createRecruitmentInfo( when(clubRecruitmentInfo.getId()).thenReturn(id); when(clubRecruitmentInfo.getLogo()).thenReturn(logo); when(clubRecruitmentInfo.getIntroduction()).thenReturn(introduction); - when(clubRecruitmentInfo.getDescription()).thenReturn(description); when(clubRecruitmentInfo.getPresidentName()).thenReturn(presidentName); when(clubRecruitmentInfo.getPresidentTelephoneNumber()).thenReturn(presidentTelephoneNumber); when(clubRecruitmentInfo.getRecruitmentStart()).thenReturn(ZonedDateTime.from(recruitmentStart)); diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java index 573b8910a..c1939a4b5 100644 --- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -1,14 +1,12 @@ package moadong.fixture; import moadong.club.entity.ClubDescription; -import moadong.club.entity.Faq; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import java.time.Instant; -import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; @@ -23,7 +21,6 @@ public static ClubInfoRequest createValidClubInfoRequest() { "동아리 소개입니다.", "홍길동", ClubDescription.builder().build(), - List.of(), "010-1234-5678", Map.of("insta", "https://test") ); From 6037308b8ec706f293a82b76db643e1b22ec368a Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 20:27:00 +0900 Subject: [PATCH 59/69] =?UTF-8?q?fix:=20ClubDescription=20NPE=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index b8e8ed03d..9b0fb2d39 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -56,6 +56,7 @@ public Club() { this.division = ""; this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); + this.description = ClubDescription.builder().build(); } public Club(String userId) { @@ -65,6 +66,7 @@ public Club(String userId) { this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); this.userId = userId; + this.description = ClubDescription.builder().build(); } public Club(String id, String userId) { @@ -75,6 +77,7 @@ public Club(String id, String userId) { this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); this.userId = userId; + this.description = ClubDescription.builder().build(); } @Builder @@ -84,6 +87,7 @@ public Club(String name, String category, String division, this.category = category; this.division = division; this.clubRecruitmentInformation = clubRecruitmentInformation; + this.description = ClubDescription.builder().build(); } public void update(ClubInfoRequest request) { From 8f43ad01bf842bcaf0692f970bd23858716bacfa Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Tue, 23 Dec 2025 20:28:43 +0900 Subject: [PATCH 60/69] =?UTF-8?q?fix:=20ClubDescription=20NPE=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 13 +++++++------ .../club/payload/dto/ClubDetailedResult.java | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 9b0fb2d39..25dbdd713 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -46,7 +46,8 @@ public class Club implements Persistable { @Field("recruitmentInformation") private ClubRecruitmentInformation clubRecruitmentInformation; - private ClubDescription description; + @Field("description") + private ClubDescription clubDescription; @Version private Long version; @@ -56,7 +57,7 @@ public Club() { this.division = ""; this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); - this.description = ClubDescription.builder().build(); + this.clubDescription = ClubDescription.builder().build(); } public Club(String userId) { @@ -66,7 +67,7 @@ public Club(String userId) { this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); this.userId = userId; - this.description = ClubDescription.builder().build(); + this.clubDescription = ClubDescription.builder().build(); } public Club(String id, String userId) { @@ -77,7 +78,7 @@ public Club(String id, String userId) { this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); this.userId = userId; - this.description = ClubDescription.builder().build(); + this.clubDescription = ClubDescription.builder().build(); } @Builder @@ -87,7 +88,7 @@ public Club(String name, String category, String division, this.category = category; this.division = division; this.clubRecruitmentInformation = clubRecruitmentInformation; - this.description = ClubDescription.builder().build(); + this.clubDescription = ClubDescription.builder().build(); } public void update(ClubInfoRequest request) { @@ -100,7 +101,7 @@ public void update(ClubInfoRequest request) { this.state = ClubState.AVAILABLE; this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); - this.description = request.description(); + this.clubDescription = request.description(); } private void validateTags(List tags) { diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index a5614abe3..31013cc34 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -67,7 +67,7 @@ public static ClubDetailedResult of(Club club) { .division(club.getDivision() == null ? "" : club.getDivision()) .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" : clubRecruitmentInformation.getIntroduction()) - .description(club.getDescription()) + .description(club.getClubDescription()) .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" : clubRecruitmentInformation.getPresidentName()) .presidentPhoneNumber( @@ -83,8 +83,8 @@ public static ClubDetailedResult of(Club club) { club.getClubRecruitmentInformation().getExternalApplicationUrl()) .socialLinks(club.getSocialLinks() == null ? Map.of() : club.getSocialLinks()) - .faqs(club.getDescription().getFaqs() == null ? List.of() - : club.getDescription().getFaqs()) + .faqs(club.getClubDescription().getFaqs() == null ? List.of() + : club.getClubDescription().getFaqs()) .lastModifiedDate(lastModifiedDate) .build(); } From 052430c667aedfecd1237963149a2b3bf978cc7e Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 00:22:45 +0900 Subject: [PATCH 61/69] =?UTF-8?q?refactor:=20clubDescription=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entity를 dto로 사용하던 코드 리팩토링하였습니다. --- .../main/java/moadong/club/entity/Club.java | 2 +- .../java/moadong/club/entity/ClubAward.java | 11 ++++++ .../moadong/club/entity/ClubDescription.java | 19 +++++++++ .../club/entity/ClubIdealCandidate.java | 11 ++++++ .../main/java/moadong/club/entity/Faq.java | 6 +++ .../club/payload/dto/ClubAwardDto.java | 18 +++++++++ .../club/payload/dto/ClubDescriptionDto.java | 39 +++++++++++++++++++ .../club/payload/dto/ClubDetailedResult.java | 10 ++--- .../payload/dto/ClubIdealCandidateDto.java | 18 +++++++++ .../java/moadong/club/payload/dto/FaqDto.java | 17 ++++++++ .../club/payload/request/ClubInfoRequest.java | 4 +- .../moadong/fixture/ClubRequestFixture.java | 3 +- 12 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java create mode 100644 backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java create mode 100644 backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java create mode 100644 backend/src/main/java/moadong/club/payload/dto/FaqDto.java diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 25dbdd713..f74ff704c 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -101,7 +101,7 @@ public void update(ClubInfoRequest request) { this.state = ClubState.AVAILABLE; this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); - this.clubDescription = request.description(); + this.clubDescription = ClubDescription.from(request.description()); } private void validateTags(List tags) { diff --git a/backend/src/main/java/moadong/club/entity/ClubAward.java b/backend/src/main/java/moadong/club/entity/ClubAward.java index 9b3ae019d..ff0fb3ebc 100644 --- a/backend/src/main/java/moadong/club/entity/ClubAward.java +++ b/backend/src/main/java/moadong/club/entity/ClubAward.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import moadong.club.payload.dto.ClubAwardDto; import java.util.List; @@ -12,6 +13,16 @@ @AllArgsConstructor @NoArgsConstructor public class ClubAward { + private String semester; + private List achievements; + + public static ClubAward from(ClubAwardDto dto) { + if (dto == null) return null; + return ClubAward.builder() + .semester(dto.semester()) + .achievements(dto.achievements()) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/entity/ClubDescription.java b/backend/src/main/java/moadong/club/entity/ClubDescription.java index b2265c861..53f9d9c05 100644 --- a/backend/src/main/java/moadong/club/entity/ClubDescription.java +++ b/backend/src/main/java/moadong/club/entity/ClubDescription.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import moadong.club.payload.dto.ClubDescriptionDto; import java.util.List; @@ -12,10 +13,28 @@ @AllArgsConstructor @NoArgsConstructor public class ClubDescription { + private String introDescription; + private String activityDescription; + private List awards; + private ClubIdealCandidate idealCandidate; + private String benefits; + private List faqs; + + public static ClubDescription from(ClubDescriptionDto dto) { + if (dto == null) return null; + return ClubDescription.builder() + .introDescription(dto.introDescription()) + .activityDescription(dto.activityDescription()) + .awards(dto.awards() == null ? null : dto.awards().stream().map(ClubAward::from).toList()) + .idealCandidate(ClubIdealCandidate.from(dto.idealCandidate())) + .benefits(dto.benefits()) + .faqs(dto.faqs() == null ? null : dto.faqs().stream().map(Faq::from).toList()) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java index 8ae3f13ff..dedc01fb1 100644 --- a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java +++ b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import moadong.club.payload.dto.ClubIdealCandidateDto; import java.util.List; @@ -12,6 +13,16 @@ @AllArgsConstructor @NoArgsConstructor public class ClubIdealCandidate { + private List tags; + private String content; + + public static ClubIdealCandidate from(ClubIdealCandidateDto dto) { + if (dto == null) return null; + return ClubIdealCandidate.builder() + .tags(dto.tags()) + .content(dto.content()) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/entity/Faq.java b/backend/src/main/java/moadong/club/entity/Faq.java index fd0e271f0..419935ad8 100644 --- a/backend/src/main/java/moadong/club/entity/Faq.java +++ b/backend/src/main/java/moadong/club/entity/Faq.java @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import moadong.club.payload.dto.FaqDto; @NoArgsConstructor @AllArgsConstructor @@ -10,4 +11,9 @@ public class Faq { String question; String answer; + + public static Faq from(FaqDto dto) { + if (dto == null) return null; + return new Faq(dto.question(), dto.answer()); + } } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java new file mode 100644 index 000000000..21ff982dd --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java @@ -0,0 +1,18 @@ +package moadong.club.payload.dto; + +import jakarta.validation.constraints.Size; +import moadong.club.entity.ClubAward; + +import java.util.List; + +public record ClubAwardDto( + @Size(max = 50) + String semester, + + List<@Size(max = 100) String> achievements +) { + public static ClubAwardDto from(ClubAward clubAward) { + if (clubAward == null) return null; + return new ClubAwardDto(clubAward.getSemester(), clubAward.getAchievements()); + } +} diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java new file mode 100644 index 000000000..e94afd161 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java @@ -0,0 +1,39 @@ +package moadong.club.payload.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import moadong.club.entity.ClubDescription; + +import java.util.List; + +public record ClubDescriptionDto( + @Size(max = 500) + String introDescription, + + @Size(max = 1000) + String activityDescription, + + @Valid + List awards, + + @Valid + ClubIdealCandidateDto idealCandidate, + + @Size(max = 1000) + String benefits, + + @Valid + List faqs +) { + public static ClubDescriptionDto from(ClubDescription description) { + if (description == null) return null; + return new ClubDescriptionDto( + description.getIntroDescription(), + description.getActivityDescription(), + description.getAwards() == null ? null : description.getAwards().stream().map(ClubAwardDto::from).toList(), + ClubIdealCandidateDto.from(description.getIdealCandidate()), + description.getBenefits(), + description.getFaqs() == null ? null : description.getFaqs().stream().map(FaqDto::from).toList() + ); + } +} diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 31013cc34..18818b4f3 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -2,9 +2,7 @@ import lombok.Builder; import moadong.club.entity.Club; -import moadong.club.entity.ClubDescription; import moadong.club.entity.ClubRecruitmentInformation; -import moadong.club.entity.Faq; import java.time.format.DateTimeFormatter; import java.util.List; @@ -20,7 +18,7 @@ public record ClubDetailedResult( String state, List feeds, String introduction, - ClubDescription description, + ClubDescriptionDto description, String presidentName, String presidentPhoneNumber, String recruitmentStart, @@ -31,7 +29,7 @@ public record ClubDetailedResult( Map socialLinks, String category, String division, - List faqs, + List faqs, String lastModifiedDate ) { @@ -67,7 +65,7 @@ public static ClubDetailedResult of(Club club) { .division(club.getDivision() == null ? "" : club.getDivision()) .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" : clubRecruitmentInformation.getIntroduction()) - .description(club.getClubDescription()) + .description(ClubDescriptionDto.from(club.getClubDescription())) .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" : clubRecruitmentInformation.getPresidentName()) .presidentPhoneNumber( @@ -84,7 +82,7 @@ public static ClubDetailedResult of(Club club) { .socialLinks(club.getSocialLinks() == null ? Map.of() : club.getSocialLinks()) .faqs(club.getClubDescription().getFaqs() == null ? List.of() - : club.getClubDescription().getFaqs()) + : club.getClubDescription().getFaqs().stream().map(FaqDto::from).toList()) .lastModifiedDate(lastModifiedDate) .build(); } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java new file mode 100644 index 000000000..0ecce0466 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java @@ -0,0 +1,18 @@ +package moadong.club.payload.dto; + +import jakarta.validation.constraints.Size; +import moadong.club.entity.ClubIdealCandidate; + +import java.util.List; + +public record ClubIdealCandidateDto( + List<@Size(max = 10) String> tags, + + @Size(max = 700) + String content +) { + public static ClubIdealCandidateDto from(ClubIdealCandidate candidate) { + if (candidate == null) return null; + return new ClubIdealCandidateDto(candidate.getTags(), candidate.getContent()); + } +} diff --git a/backend/src/main/java/moadong/club/payload/dto/FaqDto.java b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java new file mode 100644 index 000000000..58a532435 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java @@ -0,0 +1,17 @@ +package moadong.club.payload.dto; + +import jakarta.validation.constraints.Size; +import moadong.club.entity.Faq; + +public record FaqDto( + @Size(max = 100) + String question, + + @Size(max = 500) + String answer +) { + public static FaqDto from(Faq faq) { + if (faq == null) return null; + return new FaqDto(faq.getQuestion(), faq.getAnswer()); + } +} diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java index a9f4962da..24e481886 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java @@ -1,7 +1,7 @@ package moadong.club.payload.request; import jakarta.validation.constraints.NotBlank; -import moadong.club.entity.ClubDescription; +import moadong.club.payload.dto.ClubDescriptionDto; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; import moadong.global.annotation.PhoneNumber; @@ -17,7 +17,7 @@ public record ClubInfoRequest( List tags, String introduction, String presidentName, - ClubDescription description, + ClubDescriptionDto description, @PhoneNumber String presidentPhoneNumber, Map socialLinks diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java index c1939a4b5..2761d8866 100644 --- a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -3,6 +3,7 @@ import moadong.club.entity.ClubDescription; import moadong.club.enums.ClubCategory; import moadong.club.enums.ClubDivision; +import moadong.club.payload.dto.ClubDescriptionDto; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; @@ -20,7 +21,7 @@ public static ClubInfoRequest createValidClubInfoRequest() { List.of("개발", "스터디"), "동아리 소개입니다.", "홍길동", - ClubDescription.builder().build(), + ClubDescriptionDto.from(ClubDescription.builder().build()), "010-1234-5678", Map.of("insta", "https://test") ); From df505cbc002bb3cdfeef81a6e55feeab34329734 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 00:27:50 +0900 Subject: [PATCH 62/69] =?UTF-8?q?refactor:=20clubDescription=20dto?= =?UTF-8?q?=EC=99=80=20=EC=97=94=ED=8B=B0=ED=8B=B0=EA=B0=84=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 2 +- .../main/java/moadong/club/entity/ClubAward.java | 9 --------- .../java/moadong/club/entity/ClubDescription.java | 13 ------------- .../moadong/club/entity/ClubIdealCandidate.java | 9 --------- backend/src/main/java/moadong/club/entity/Faq.java | 6 ------ .../java/moadong/club/payload/dto/ClubAwardDto.java | 7 +++++++ .../club/payload/dto/ClubDescriptionDto.java | 11 +++++++++++ .../club/payload/dto/ClubIdealCandidateDto.java | 7 +++++++ .../main/java/moadong/club/payload/dto/FaqDto.java | 4 ++++ 9 files changed, 30 insertions(+), 38 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index f74ff704c..60441a058 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -101,7 +101,7 @@ public void update(ClubInfoRequest request) { this.state = ClubState.AVAILABLE; this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); - this.clubDescription = ClubDescription.from(request.description()); + this.clubDescription = request.description().toEntity(); } private void validateTags(List tags) { diff --git a/backend/src/main/java/moadong/club/entity/ClubAward.java b/backend/src/main/java/moadong/club/entity/ClubAward.java index ff0fb3ebc..99a8c2893 100644 --- a/backend/src/main/java/moadong/club/entity/ClubAward.java +++ b/backend/src/main/java/moadong/club/entity/ClubAward.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import moadong.club.payload.dto.ClubAwardDto; import java.util.List; @@ -17,12 +16,4 @@ public class ClubAward { private String semester; private List achievements; - - public static ClubAward from(ClubAwardDto dto) { - if (dto == null) return null; - return ClubAward.builder() - .semester(dto.semester()) - .achievements(dto.achievements()) - .build(); - } } diff --git a/backend/src/main/java/moadong/club/entity/ClubDescription.java b/backend/src/main/java/moadong/club/entity/ClubDescription.java index 53f9d9c05..caa22e009 100644 --- a/backend/src/main/java/moadong/club/entity/ClubDescription.java +++ b/backend/src/main/java/moadong/club/entity/ClubDescription.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import moadong.club.payload.dto.ClubDescriptionDto; import java.util.List; @@ -25,16 +24,4 @@ public class ClubDescription { private String benefits; private List faqs; - - public static ClubDescription from(ClubDescriptionDto dto) { - if (dto == null) return null; - return ClubDescription.builder() - .introDescription(dto.introDescription()) - .activityDescription(dto.activityDescription()) - .awards(dto.awards() == null ? null : dto.awards().stream().map(ClubAward::from).toList()) - .idealCandidate(ClubIdealCandidate.from(dto.idealCandidate())) - .benefits(dto.benefits()) - .faqs(dto.faqs() == null ? null : dto.faqs().stream().map(Faq::from).toList()) - .build(); - } } diff --git a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java index dedc01fb1..ba3bfcdf5 100644 --- a/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java +++ b/backend/src/main/java/moadong/club/entity/ClubIdealCandidate.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import moadong.club.payload.dto.ClubIdealCandidateDto; import java.util.List; @@ -17,12 +16,4 @@ public class ClubIdealCandidate { private List tags; private String content; - - public static ClubIdealCandidate from(ClubIdealCandidateDto dto) { - if (dto == null) return null; - return ClubIdealCandidate.builder() - .tags(dto.tags()) - .content(dto.content()) - .build(); - } } diff --git a/backend/src/main/java/moadong/club/entity/Faq.java b/backend/src/main/java/moadong/club/entity/Faq.java index 419935ad8..fd0e271f0 100644 --- a/backend/src/main/java/moadong/club/entity/Faq.java +++ b/backend/src/main/java/moadong/club/entity/Faq.java @@ -3,7 +3,6 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import moadong.club.payload.dto.FaqDto; @NoArgsConstructor @AllArgsConstructor @@ -11,9 +10,4 @@ public class Faq { String question; String answer; - - public static Faq from(FaqDto dto) { - if (dto == null) return null; - return new Faq(dto.question(), dto.answer()); - } } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java index 21ff982dd..ef793e595 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubAwardDto.java @@ -15,4 +15,11 @@ public static ClubAwardDto from(ClubAward clubAward) { if (clubAward == null) return null; return new ClubAwardDto(clubAward.getSemester(), clubAward.getAchievements()); } + + public ClubAward toEntity() { + return ClubAward.builder() + .semester(semester) + .achievements(achievements) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java index e94afd161..9f1b836b3 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDescriptionDto.java @@ -36,4 +36,15 @@ public static ClubDescriptionDto from(ClubDescription description) { description.getFaqs() == null ? null : description.getFaqs().stream().map(FaqDto::from).toList() ); } + + public ClubDescription toEntity() { + return ClubDescription.builder() + .introDescription(introDescription) + .activityDescription(activityDescription) + .awards(awards == null ? null : awards.stream().map(ClubAwardDto::toEntity).toList()) + .idealCandidate(idealCandidate == null ? null : idealCandidate.toEntity()) + .benefits(benefits) + .faqs(faqs == null ? null : faqs.stream().map(FaqDto::toEntity).toList()) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java index 0ecce0466..87e00d6ce 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubIdealCandidateDto.java @@ -15,4 +15,11 @@ public static ClubIdealCandidateDto from(ClubIdealCandidate candidate) { if (candidate == null) return null; return new ClubIdealCandidateDto(candidate.getTags(), candidate.getContent()); } + + public ClubIdealCandidate toEntity() { + return ClubIdealCandidate.builder() + .tags(tags) + .content(content) + .build(); + } } diff --git a/backend/src/main/java/moadong/club/payload/dto/FaqDto.java b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java index 58a532435..9bec92497 100644 --- a/backend/src/main/java/moadong/club/payload/dto/FaqDto.java +++ b/backend/src/main/java/moadong/club/payload/dto/FaqDto.java @@ -14,4 +14,8 @@ public static FaqDto from(Faq faq) { if (faq == null) return null; return new FaqDto(faq.getQuestion(), faq.getAnswer()); } + + public Faq toEntity() { + return new Faq(question, answer); + } } From a9b2716f246329b34805abd34c5e61faa557e3d9 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 00:39:39 +0900 Subject: [PATCH 63/69] =?UTF-8?q?refactor:=20faqs=20=EC=9C=84=EC=B9=98=20d?= =?UTF-8?q?escription=EC=97=90=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/payload/dto/ClubDetailedResult.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 18818b4f3..e46c7e1cf 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -29,7 +29,6 @@ public record ClubDetailedResult( Map socialLinks, String category, String division, - List faqs, String lastModifiedDate ) { @@ -81,8 +80,6 @@ public static ClubDetailedResult of(Club club) { club.getClubRecruitmentInformation().getExternalApplicationUrl()) .socialLinks(club.getSocialLinks() == null ? Map.of() : club.getSocialLinks()) - .faqs(club.getClubDescription().getFaqs() == null ? List.of() - : club.getClubDescription().getFaqs().stream().map(FaqDto::from).toList()) .lastModifiedDate(lastModifiedDate) .build(); } From eff6ec8c0ef4564ac312b10cc3ca3f764389f78e Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 00:53:21 +0900 Subject: [PATCH 64/69] =?UTF-8?q?refactor:=20=ED=97=88=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=99=B8=EB=B6=80=20=EC=A7=80=EC=9B=90=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EB=B6=88=EB=B3=80=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/moadong/club/entity/ClubApplicationForm.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java index b51198d96..0b81a5edc 100644 --- a/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationForm.java @@ -27,7 +27,7 @@ @Getter @Builder(toBuilder = true) public class ClubApplicationForm implements Persistable { - private static String[] externalApplicationUrlAllowed = {"https://forms.gle", "https://docs.google.com/forms", "https://form.naver.com", "https://naver.me"}; + private static final String[] externalApplicationUrlAllowed = {"https://forms.gle", "https://docs.google.com/forms", "https://form.naver.com", "https://naver.me"}; @Id private String id; From fd9b168c2d38e23fb345524131ecd2e5c75c02ad Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 00:53:55 +0900 Subject: [PATCH 65/69] =?UTF-8?q?fix:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EA=B4=80=EB=A0=A8=20api=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=82=AC=EC=9A=A9=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=AF=B8=EC=9D=B8=EC=A6=9D=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/controller/ClubImageController.java | 39 +++++++++++-------- .../media/service/CloudflareImageService.java | 39 +++++++++++-------- .../media/service/ClubImageService.java | 16 ++++---- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/backend/src/main/java/moadong/media/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java index 3921b678d..e2f7a0ee9 100644 --- a/backend/src/main/java/moadong/media/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -9,6 +9,8 @@ import moadong.media.dto.UploadCompleteRequest; import moadong.media.dto.UploadUrlRequest; import moadong.media.service.ClubImageService; +import moadong.user.annotation.CurrentUser; +import moadong.user.payload.CustomUserDetails; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -36,47 +38,50 @@ public ClubImageController(@Qualifier("cloudflare") ClubImageService clubImageSe @DeleteMapping(value = "/{clubId}/logo") @Operation(summary = "로고 이미지 삭제", description = "로고 이미지를 저장소에서 삭제합니다.") - public ResponseEntity deleteLogo(@PathVariable String clubId) { - clubImageService.deleteLogo(clubId); + public ResponseEntity deleteLogo(@PathVariable String clubId, @CurrentUser CustomUserDetails user) { + clubImageService.deleteLogo(clubId, user.getId()); return Response.ok("success delete logo"); } @PostMapping(value = "/{clubId}/feeds") @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "피드 이미지의 설정을 업데이트 합니다.") - public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody @Valid FeedUpdateRequest feeds) { - clubImageService.updateFeeds(clubId, feeds.feeds()); + public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody @Valid FeedUpdateRequest feeds, @CurrentUser CustomUserDetails user) { + clubImageService.updateFeeds(clubId, user.getId(), feeds.feeds()); return Response.ok("success put feeds"); } @DeleteMapping(value = "/{clubId}/cover") @Operation(summary = "커버 이미지 삭제", description = "커버 이미지를 저장소에서 삭제합니다.") - public ResponseEntity deleteCover(@PathVariable String clubId) { - clubImageService.deleteCover(clubId); + public ResponseEntity deleteCover(@PathVariable String clubId, @CurrentUser CustomUserDetails user) { + clubImageService.deleteCover(clubId, user.getId()); return Response.ok("success delete cover"); } @PostMapping("/{clubId}/logo/upload-url") @Operation(summary = "로고 이미지 업로드 URL 생성", description = "로고 이미지 업로드를 위한 Presigned URL을 생성합니다.") public ResponseEntity generateLogoUploadUrl(@PathVariable String clubId, - @RequestBody @Valid UploadUrlRequest request) { + @RequestBody @Valid UploadUrlRequest request, + @CurrentUser CustomUserDetails user) { PresignedUploadResponse response = clubImageService.generateLogoUploadUrl( - clubId, request.fileName(), request.contentType()); + clubId, user.getId(), request.fileName(), request.contentType()); return Response.ok(response); } @PostMapping("/{clubId}/logo/complete") @Operation(summary = "로고 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") public ResponseEntity completeLogoUpload(@PathVariable String clubId, - @RequestBody @Valid UploadCompleteRequest request) { - clubImageService.completeLogoUpload(clubId, request.fileUrl()); + @RequestBody @Valid UploadCompleteRequest request, + @CurrentUser CustomUserDetails user) { + clubImageService.completeLogoUpload(clubId, user.getId(), request.fileUrl()); return Response.ok("success upload logo"); } @PostMapping("/{clubId}/feed/upload-url") @Operation(summary = "피드 이미지 업로드 URL들 생성", description = "피드 이미지 업로드를 위한 Presigned URL을 여러 개 한 번에 생성합니다.") public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, - @RequestBody @Valid List requests) { - List results = clubImageService.generateFeedUploadUrls(clubId, requests); + @RequestBody @Valid List requests, + @CurrentUser CustomUserDetails user) { + List results = clubImageService.generateFeedUploadUrls(clubId, user.getId(), requests); return Response.ok(results); } @@ -85,17 +90,19 @@ public ResponseEntity generateFeedUploadUrl(@PathVariable String clubId, @PostMapping("/{clubId}/cover/upload-url") @Operation(summary = "커버 이미지 업로드 URL 생성", description = "커버 이미지 업로드를 위한 Presigned URL을 생성합니다.") public ResponseEntity generateCoverUploadUrl(@PathVariable String clubId, - @RequestBody @Valid UploadUrlRequest request) { + @RequestBody @Valid UploadUrlRequest request, + @CurrentUser CustomUserDetails user) { PresignedUploadResponse response = clubImageService.generateCoverUploadUrl( - clubId, request.fileName(), request.contentType()); + clubId, user.getId(), request.fileName(), request.contentType()); return Response.ok(response); } @PostMapping("/{clubId}/cover/complete") @Operation(summary = "커버 이미지 업로드 완료", description = "클라이언트가 Presigned URL로 업로드한 후 호출하는 완료 API입니다.") public ResponseEntity completeCoverUpload(@PathVariable String clubId, - @RequestBody @Valid UploadCompleteRequest request) { - clubImageService.completeCoverUpload(clubId, request.fileUrl()); + @RequestBody @Valid UploadCompleteRequest request, + @CurrentUser CustomUserDetails user) { + clubImageService.completeCoverUpload(clubId, user.getId(), request.fileUrl()); return Response.ok("success upload cover"); } } diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java index 3ec55df1d..363529767 100644 --- a/backend/src/main/java/moadong/media/service/CloudflareImageService.java +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -68,8 +68,8 @@ private void init() { @Override @Transactional - public void deleteLogo(String clubId) { - Club club = getClub(clubId); + public void deleteLogo(String clubId, String userId) { + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getLogo() != null) { @@ -81,8 +81,8 @@ public void deleteLogo(String clubId) { @Override @Transactional - public void updateFeeds(String clubId, List newFeedImageList) { - Club club = getClub(clubId); + public void updateFeeds(String clubId, String userId, List newFeedImageList) { + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); if (newFeedImageList == null) { @@ -109,10 +109,15 @@ public void updateFeeds(String clubId, List newFeedImageList) { } - private Club getClub(String clubId) { + private Club getAuthorizedClub(String clubId, String userId) { ObjectId objectId = ObjectIdConverter.convertString(clubId); - return clubRepository.findClubById(objectId) + Club club = clubRepository.findClubById(objectId) .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (!club.getUserId().equals(userId)) { + throw new RestApiException(ErrorCode.USER_UNAUTHORIZED); + } + return club; } private void deleteFeedImages(Club club, List feedImages, List newFeedImages) { @@ -153,8 +158,8 @@ public void deleteFile(Club club, String filePath) { @Override @Transactional - public void deleteCover(String clubId) { - Club club = getClub(clubId); + public void deleteCover(String clubId, String userId) { + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getCover() != null) { @@ -165,14 +170,15 @@ public void deleteCover(String clubId) { } @Override - public PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileName, String contentType) { + public PresignedUploadResponse generateLogoUploadUrl(String clubId, String userId, String fileName, String contentType) { + getAuthorizedClub(clubId, userId); validateFileName(fileName); return generatePresignedUrl(clubId, fileName, contentType, FileType.LOGO); } @Override - public List generateFeedUploadUrls(String clubId, List requests) { - Club club = getClub(clubId); + public List generateFeedUploadUrls(String clubId, String userId, List requests) { + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); int existingCount = (club.getClubRecruitmentInformation().getFeedImages() == null) ? 0 @@ -203,16 +209,17 @@ public List generateFeedUploadUrls(String clubId, List< } @Override - public PresignedUploadResponse generateCoverUploadUrl(String clubId, String fileName, String contentType) { + public PresignedUploadResponse generateCoverUploadUrl(String clubId, String userId, String fileName, String contentType) { + getAuthorizedClub(clubId, userId); validateFileName(fileName); return generatePresignedUrl(clubId, fileName, contentType, FileType.COVER); } @Override @Transactional - public void completeLogoUpload(String clubId, String fileUrl) { + public void completeLogoUpload(String clubId, String userId, String fileUrl) { validateFileConstraints(clubId, FileType.LOGO, fileUrl); - Club club = getClub(clubId); + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getLogo() != null) { @@ -225,9 +232,9 @@ public void completeLogoUpload(String clubId, String fileUrl) { @Override @Transactional - public void completeCoverUpload(String clubId, String fileUrl) { + public void completeCoverUpload(String clubId, String userId, String fileUrl) { validateFileConstraints(clubId, FileType.COVER, fileUrl); - Club club = getClub(clubId); + Club club = getAuthorizedClub(clubId, userId); validateClubRecruitmentInformation(club); if (club.getClubRecruitmentInformation().getCover() != null) { diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java index 751319fd0..3fa063d4d 100644 --- a/backend/src/main/java/moadong/media/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -7,22 +7,22 @@ public interface ClubImageService { - void deleteLogo(String clubId); + void deleteLogo(String clubId, String userId); - void updateFeeds(String clubId, List newFeedImageList); + void updateFeeds(String clubId, String userId, List newFeedImageList); void deleteFile(Club club, String filePath); - void deleteCover(String clubId); + void deleteCover(String clubId, String userId); // Presigned URL 방식 메서드 - PresignedUploadResponse generateLogoUploadUrl(String clubId, String fileName, String contentType); + PresignedUploadResponse generateLogoUploadUrl(String clubId, String userId, String fileName, String contentType); - List generateFeedUploadUrls(String clubId, List requests); + List generateFeedUploadUrls(String clubId, String userId, List requests); - PresignedUploadResponse generateCoverUploadUrl(String clubId, String fileName, String contentType); + PresignedUploadResponse generateCoverUploadUrl(String clubId, String userId, String fileName, String contentType); - void completeLogoUpload(String clubId, String fileUrl); + void completeLogoUpload(String clubId, String userId, String fileUrl); - void completeCoverUpload(String clubId, String fileUrl); + void completeCoverUpload(String clubId, String userId, String fileUrl); } From 283739d053626487726ef98fbeb70279a7e66c40 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 01:20:58 +0900 Subject: [PATCH 66/69] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/java/moadong/club/entity/Club.java | 2 ++ .../CloudflareImageServiceUpdateFeedsLimitTest.java | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 60441a058..4e81a5333 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -83,10 +83,12 @@ public Club(String id, String userId) { @Builder public Club(String name, String category, String division, + String userId, ClubRecruitmentInformation clubRecruitmentInformation) { this.name = name; this.category = category; this.division = division; + this.userId = userId; this.clubRecruitmentInformation = clubRecruitmentInformation; this.clubDescription = ClubDescription.builder().build(); } diff --git a/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java index b5839b372..bea3b781a 100644 --- a/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java +++ b/backend/src/test/java/moadong/media/service/CloudflareImageServiceUpdateFeedsLimitTest.java @@ -52,7 +52,7 @@ void setUp() { ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() .feedImages(List.of()) .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); + club = Club.builder().userId("").clubRecruitmentInformation(info).build(); objectId = new ObjectId(); } @@ -63,14 +63,14 @@ void setUp() { ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() .feedImages(tooMany) .build(); - club = Club.builder().clubRecruitmentInformation(info).build(); - when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); + club = Club.builder().userId("").clubRecruitmentInformation(info).build(); // when - RestApiException exception = assertThrows(RestApiException.class, - () -> cloudflareImageService.updateFeeds(objectId.toHexString(), tooMany)); + when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); // then + RestApiException exception = assertThrows(RestApiException.class, + () -> cloudflareImageService.updateFeeds(objectId.toHexString(), "", tooMany)); assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); } } From 616dcbbdc724d1203d03ac6c04b9bfa0a7fac3c2 Mon Sep 17 00:00:00 2001 From: lepitaaar Date: Wed, 24 Dec 2025 01:38:48 +0900 Subject: [PATCH 67/69] =?UTF-8?q?refactor:=20recruitmentStatus=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EA=B0=92=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/club/payload/dto/ClubDetailedResult.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index e46c7e1cf..5907da911 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -75,7 +75,7 @@ public static ClubDetailedResult of(Club club) { .recruitmentTarget(clubRecruitmentInformation.getRecruitmentTarget() == null ? "" : clubRecruitmentInformation.getRecruitmentTarget()) .recruitmentStatus(clubRecruitmentInformation.getClubRecruitmentStatus() == null - ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().getDescription()) + ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().toString()) .externalApplicationUrl(club.getClubRecruitmentInformation().getExternalApplicationUrl() == null ? "" : club.getClubRecruitmentInformation().getExternalApplicationUrl()) .socialLinks(club.getSocialLinks() == null ? Map.of() From 91fbc3d9167839cd6bf100d78d8f8744651ff65e Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 24 Dec 2025 19:24:31 +0900 Subject: [PATCH 68/69] =?UTF-8?q?feature:=20refresh=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EB=B0=9C=EA=B8=89=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/user/entity/User.java | 43 +++++++++++++++++-- .../user/repository/UserRepository.java | 2 +- .../user/service/UserCommandService.java | 32 ++++++++++---- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/moadong/user/entity/User.java b/backend/src/main/java/moadong/user/entity/User.java index 765029574..7dedd80b8 100644 --- a/backend/src/main/java/moadong/user/entity/User.java +++ b/backend/src/main/java/moadong/user/entity/User.java @@ -2,8 +2,12 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; + +import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.List; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -51,8 +55,9 @@ public class User implements UserDetails { private Date lastLoginAt; - @Field("refreshToken") - private RefreshToken refreshToken; + @Builder.Default + @Field("refreshTokens") + private List refreshTokens = new ArrayList<>(); @Field("userInformation") private UserInformation userInformation; @@ -92,8 +97,38 @@ public void updateClubId(String clubId) { this.clubId = clubId; } - public void updateRefreshToken(RefreshToken refreshToken) { - this.refreshToken = refreshToken; + public void addRefreshToken(RefreshToken refreshToken) { + if (this.refreshTokens == null) { + this.refreshTokens = new ArrayList<>(); + } + this.refreshTokens.add(refreshToken); + } + + public void replaceRefreshToken(String oldToken, RefreshToken newToken) { + if (this.refreshTokens == null) { + this.refreshTokens = new ArrayList<>(); + return; + } + for (int i = 0; i < this.refreshTokens.size(); i++) { + if (this.refreshTokens.get(i).getToken().equals(oldToken)) { + this.refreshTokens.set(i, newToken); + return; + } + } + } + + public void removeRefreshToken(String refreshToken) { + if (this.refreshTokens == null) { + return; + } + this.refreshTokens.removeIf(t -> t.getToken().equals(refreshToken)); } + public void removeAllRefreshTokens() { + if (this.refreshTokens == null) { + this.refreshTokens = new ArrayList<>(); + return; + } + this.refreshTokens.clear(); + } } diff --git a/backend/src/main/java/moadong/user/repository/UserRepository.java b/backend/src/main/java/moadong/user/repository/UserRepository.java index d93320bca..816cf6689 100644 --- a/backend/src/main/java/moadong/user/repository/UserRepository.java +++ b/backend/src/main/java/moadong/user/repository/UserRepository.java @@ -9,5 +9,5 @@ public interface UserRepository extends MongoRepository { Optional findUserByUserId(String userId); - Optional findUserByRefreshToken_Token(String token); + Optional findUserByRefreshTokens_Token(String token); } diff --git a/backend/src/main/java/moadong/user/service/UserCommandService.java b/backend/src/main/java/moadong/user/service/UserCommandService.java index 864dc63db..8e57e2ec9 100644 --- a/backend/src/main/java/moadong/user/service/UserCommandService.java +++ b/backend/src/main/java/moadong/user/service/UserCommandService.java @@ -76,7 +76,7 @@ public LoginResponse loginUser(UserLoginRequest userLoginRequest, ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(refreshToken.getToken()); response.addHeader("Set-Cookie", cookie.toString()); - user.updateRefreshToken(refreshToken); + user.addRefreshToken(refreshToken); userRepository.save(user); return new LoginResponse(accessToken, club.getId()); } catch (MongoWriteException e) { @@ -85,10 +85,10 @@ public LoginResponse loginUser(UserLoginRequest userLoginRequest, } public void logoutUser(String refreshToken) { - User user = userRepository.findUserByRefreshToken_Token(refreshToken) + User user = userRepository.findUserByRefreshTokens_Token(refreshToken) .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); - user.updateRefreshToken(null); + user.removeRefreshToken(refreshToken); userRepository.save(user); } @@ -102,14 +102,25 @@ public RefreshResponse refreshAccessToken(String refreshToken, User user = userRepository.findUserByUserId(userId) .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); - if (!user.getRefreshToken().getToken().equals(refreshToken) - || jwtProvider.isTokenExpired(refreshToken)) { + if (jwtProvider.isTokenExpired(refreshToken)) { + throw new RestApiException(ErrorCode.TOKEN_INVALID); + } + boolean hasToken = false; + if (user.getRefreshTokens() != null) { + for (RefreshToken t : user.getRefreshTokens()) { + if (t.getToken().equals(refreshToken)) { + hasToken = true; + break; + } + } + } + if (!hasToken) { throw new RestApiException(ErrorCode.TOKEN_INVALID); } String accessToken = jwtProvider.generateAccessToken(userId); String newRefreshToken = jwtProvider.generateRefreshToken(userId).getToken(); - user.updateRefreshToken(new RefreshToken(newRefreshToken, new Date())); + user.replaceRefreshToken(refreshToken, new RefreshToken(newRefreshToken, new Date())); userRepository.save(user); ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken); @@ -134,10 +145,13 @@ public void update(String userId, user.updateUserProfile(userUpdateRequest.encryptPassword(passwordEncoder)); + user.removeAllRefreshTokens(); + RefreshToken newRefreshToken = jwtProvider.generateRefreshToken(user.getUsername()); + user.addRefreshToken(newRefreshToken); + userRepository.save(user); - String newRefreshToken = jwtProvider.generateRefreshToken(user.getUsername()).getToken(); - ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken); + ResponseCookie cookie = cookieMaker.makeRefreshTokenCookie(newRefreshToken.getToken()); response.addHeader("Set-Cookie", cookie.toString()); } @@ -152,7 +166,7 @@ public TempPasswordResponse reset(String userId) { //암호화 user.resetPassword(passwordEncoder.encode(tempPwdResponse.tempPassword())); - user.updateRefreshToken(null); + user.removeAllRefreshTokens(); userRepository.save(user); return tempPwdResponse; From 32ee4ac5e210c0b749839a14cf245036bea0ea6f Mon Sep 17 00:00:00 2001 From: yw6938 Date: Wed, 24 Dec 2025 20:14:14 +0900 Subject: [PATCH 69/69] =?UTF-8?q?refactor:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/moadong/user/service/UserCommandService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/moadong/user/service/UserCommandService.java b/backend/src/main/java/moadong/user/service/UserCommandService.java index 8e57e2ec9..03321b63e 100644 --- a/backend/src/main/java/moadong/user/service/UserCommandService.java +++ b/backend/src/main/java/moadong/user/service/UserCommandService.java @@ -102,9 +102,6 @@ public RefreshResponse refreshAccessToken(String refreshToken, User user = userRepository.findUserByUserId(userId) .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); - if (jwtProvider.isTokenExpired(refreshToken)) { - throw new RestApiException(ErrorCode.TOKEN_INVALID); - } boolean hasToken = false; if (user.getRefreshTokens() != null) { for (RefreshToken t : user.getRefreshTokens()) { @@ -117,6 +114,9 @@ public RefreshResponse refreshAccessToken(String refreshToken, if (!hasToken) { throw new RestApiException(ErrorCode.TOKEN_INVALID); } + if (jwtProvider.isTokenExpired(refreshToken)) { + throw new RestApiException(ErrorCode.TOKEN_INVALID); + } String accessToken = jwtProvider.generateAccessToken(userId); String newRefreshToken = jwtProvider.generateRefreshToken(userId).getToken();