diff --git a/src/main/java/com/jeju/nanaland/domain/common/service/ImageFileService.java b/src/main/java/com/jeju/nanaland/domain/common/service/ImageFileService.java index b5fe7d41..2ed8920d 100644 --- a/src/main/java/com/jeju/nanaland/domain/common/service/ImageFileService.java +++ b/src/main/java/com/jeju/nanaland/domain/common/service/ImageFileService.java @@ -8,6 +8,8 @@ import com.jeju.nanaland.domain.common.repository.ImageFileRepository; import com.jeju.nanaland.domain.member.service.ProfileImageService; import com.jeju.nanaland.global.exception.ServerErrorException; +import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.service.FileUploadService; import com.jeju.nanaland.global.image_upload.S3ImageService; import com.jeju.nanaland.global.image_upload.dto.S3ImageDto; import java.io.File; @@ -34,6 +36,7 @@ public class ImageFileService { private String MEMBER_PROFILE_DIRECTORY; private final S3ImageService s3ImageService; private final ImageFileRepository imageFileRepository; + private final FileUploadService fileUploadService; public ImageFile saveS3ImageFile(S3ImageDto s3ImageDto) { @@ -87,4 +90,9 @@ public void uploadMemberProfileImage(Long memberId, File file) { CompletableFuture.failedFuture(new ServerErrorException(SERVER_ERROR.getMessage())); } } + + public ImageFile getAndSaveImageFile(String fileKey) { + S3ImageDto s3ImageDto = fileUploadService.getCloudImageUrls(fileKey); + return saveS3ImageFile(s3ImageDto); + } } diff --git a/src/main/java/com/jeju/nanaland/domain/common/service/VideoFileService.java b/src/main/java/com/jeju/nanaland/domain/common/service/VideoFileService.java index 4f43c409..3a7785f9 100644 --- a/src/main/java/com/jeju/nanaland/domain/common/service/VideoFileService.java +++ b/src/main/java/com/jeju/nanaland/domain/common/service/VideoFileService.java @@ -1,28 +1,20 @@ package com.jeju.nanaland.domain.common.service; -import static com.jeju.nanaland.global.exception.ErrorCode.*; - import com.jeju.nanaland.domain.common.entity.VideoFile; import com.jeju.nanaland.domain.common.repository.VideoFileRepository; -import com.jeju.nanaland.global.exception.ServerErrorException; -import com.jeju.nanaland.global.image_upload.S3VideoService; +import com.jeju.nanaland.global.file.service.FileUploadService; import com.jeju.nanaland.global.image_upload.dto.S3VideoDto; -import java.io.File; -import java.io.IOException; -import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @RequiredArgsConstructor public class VideoFileService { - private final S3VideoService s3VideoService; private final VideoFileRepository videoFileRepository; - private final FileService fileService; + private final FileUploadService fileUploadService; public VideoFile saveS3VideoFile(S3VideoDto s3VideoDto) { VideoFile videoFile = VideoFile.builder() @@ -32,15 +24,8 @@ public VideoFile saveS3VideoFile(S3VideoDto s3VideoDto) { } // S3에 저장될 경로 지정 - public VideoFile uploadAndSaveVideoFile(File file, String directory) { - try { - MultipartFile multipartFile = fileService.convertFileToMultipartFile(file); - CompletableFuture futureVideoDto = s3VideoService.uploadVideoToS3(multipartFile, directory); - S3VideoDto s3VideoDto = futureVideoDto.join(); - return saveS3VideoFile(s3VideoDto); - } catch (IOException e) { - log.error("파일 업로드 오류: {}", e.getMessage()); - throw new ServerErrorException(FILE_FAIL_ERROR.getMessage()); - } + public VideoFile getAndSaveVideoFile(String fileKey) { + S3VideoDto s3VideoDto = fileUploadService.getCloudVideoUrls(fileKey); + return saveS3VideoFile(s3VideoDto); } } diff --git a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java index 15da749d..5ce4700b 100644 --- a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java @@ -42,9 +42,9 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); - Page findSearchDtoByKeywordsUnion(List keywords, Language language, - Pageable pageable); + Page findSearchDtoByKeywordsUnion(ExperienceType experienceType, + List keywords, Language language, Pageable pageable); - Page findSearchDtoByKeywordsIntersect(List keywords, - Language language, Pageable pageable); + Page findSearchDtoByKeywordsIntersect(ExperienceType experienceType, + List keywords, Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java index a2e13746..fb136d61 100644 --- a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java @@ -112,8 +112,8 @@ public ExperienceCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, } @Override - public Page findSearchDtoByKeywordsUnion(List keywords, - Language language, Pageable pageable) { + public Page findSearchDtoByKeywordsUnion(ExperienceType experienceType, + List keywords, Language language, Pageable pageable) { // experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 List keywordMatchQuery = queryFactory @@ -123,7 +123,9 @@ public Page findSearchDtoByKeywordsUnion(List keywo .on(hashtag.post.id.eq(experience.id) .and(hashtag.language.eq(language))) .innerJoin(hashtag.keyword, QKeyword.keyword) - .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .where( + experience.experienceType.eq(experienceType), + QKeyword.keyword.content.toLowerCase().trim().in(keywords)) .groupBy(experience.id) .fetch(); @@ -146,6 +148,7 @@ public Page findSearchDtoByKeywordsUnion(List keywo .leftJoin(experience.firstImageFile, imageFile) .leftJoin(experience.experienceTrans, experienceTrans) .on(experienceTrans.language.eq(language)) + .where(experience.experienceType.eq(experienceType)) .fetch(); // 해시태그 값을 matchedCount에 더해줌 @@ -176,8 +179,9 @@ public Page findSearchDtoByKeywordsUnion(List keywo } @Override - public Page findSearchDtoByKeywordsIntersect(List keywords, - Language language, Pageable pageable) { + public Page findSearchDtoByKeywordsIntersect( + ExperienceType experienceType, List keywords, Language language, + Pageable pageable) { // experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 List keywordMatchQuery = queryFactory @@ -187,7 +191,9 @@ public Page findSearchDtoByKeywordsIntersect(List k .on(hashtag.post.id.eq(experience.id) .and(hashtag.language.eq(language))) .innerJoin(hashtag.keyword, QKeyword.keyword) - .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .where( + experience.experienceType.eq(experienceType), + QKeyword.keyword.content.toLowerCase().trim().in(keywords)) .groupBy(experience.id) .fetch(); @@ -210,6 +216,7 @@ public Page findSearchDtoByKeywordsIntersect(List k .leftJoin(experience.firstImageFile, imageFile) .leftJoin(experience.experienceTrans, experienceTrans) .on(experienceTrans.language.eq(language)) + .where(experience.experienceType.eq(experienceType)) .fetch(); // 해시태그 값을 matchedCount에 더해줌 @@ -413,34 +420,6 @@ public PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language .fetchOne(); } - private List getIdListContainAllHashtags(String keywords, Language language) { - return queryFactory - .select(experience.id) - .from(experience) - .leftJoin(hashtag) - .on(hashtag.post.id.eq(experience.id) - .and(hashtag.category.eq(Category.EXPERIENCE)) - .and(hashtag.language.eq(language))) - .where(hashtag.keyword.content.toLowerCase().trim().in(keywords)) - .groupBy(experience.id) - .having(experience.id.count().eq(splitKeyword(keywords).stream().count())) - .fetch(); - } - - private List getIdListContainAllHashtags(List keywords, Language language) { - return queryFactory - .select(experience.id) - .from(experience) - .leftJoin(hashtag) - .on(hashtag.post.id.eq(experience.id) - .and(hashtag.category.eq(Category.EXPERIENCE)) - .and(hashtag.language.eq(language))) - .where(hashtag.keyword.content.toLowerCase().trim().in(keywords)) - .groupBy(experience.id) - .having(experience.id.count().eq(keywords.stream().count())) - .fetch(); - } - private List splitKeyword(String keyword) { String[] tokens = keyword.split("\\s+"); List tokenList = new ArrayList<>(); @@ -469,15 +448,42 @@ private BooleanExpression keywordCondition(List keywordFi } } + /** + * 공백 제거, 소문자화, '-'와 '_' 제거 + * + * @param stringExpression 조건절 컬럼 + * @return 정규화된 컬럼 + */ + private StringExpression normalizeStringExpression(StringExpression stringExpression) { + return Expressions.stringTemplate( + "replace(replace({0}, '-', ''), '_', '')", + stringExpression.toLowerCase().trim()); + } + + /** + * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 + * + * @param keywords 키워드 + * @return 키워드를 포함하는 조건 개수 + */ private Expression countMatchingWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(experienceTrans.title.toLowerCase().trim(), keywords, - 0)) - .add(countMatchingConditionWithKeyword(experienceTrans.addressTag.toLowerCase().trim(), + .add(countMatchingConditionWithKeyword(normalizeStringExpression(experienceTrans.title), keywords, 0)) + .add( + countMatchingConditionWithKeyword(normalizeStringExpression(experienceTrans.addressTag), + keywords, 0)) .add(countMatchingConditionWithKeyword(experienceTrans.content, keywords, 0)); } + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ private Expression countMatchingConditionWithKeyword(StringExpression condition, List keywords, int idx) { if (idx == keywords.size()) { @@ -490,13 +496,4 @@ private Expression countMatchingConditionWithKeyword(StringExpression c .otherwise(0) .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); } - - private BooleanExpression containsAllKeywords(StringExpression condition, List keywords) { - BooleanExpression expression = null; - for (String keyword : keywords) { - BooleanExpression containsKeyword = condition.contains(keyword); - expression = (expression == null) ? containsKeyword : expression.and(containsKeyword); - } - return expression; - } } \ No newline at end of file diff --git a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java index 09ce448e..cb5945cb 100644 --- a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java @@ -534,6 +534,18 @@ private BooleanExpression addressTagCondition(Language language, List countMatchingWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(festivalTrans.title.toLowerCase().trim(), keywords, - 0)) - .add(countMatchingConditionWithKeyword(festivalTrans.addressTag.toLowerCase().trim(), + .add(countMatchingConditionWithKeyword(normalizeStringExpression(festivalTrans.title), + keywords, 0)) + .add(countMatchingConditionWithKeyword(normalizeStringExpression(festivalTrans.addressTag), keywords, 0)) .add(countMatchingConditionWithKeyword(festivalTrans.content, keywords, 0)); } diff --git a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java index 747f72b7..72c55cbb 100644 --- a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java @@ -387,6 +387,18 @@ private BooleanExpression addressTagCondition(Language language, List countMatchingWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(marketTrans.title.toLowerCase().trim(), keywords, - 0)) - .add(countMatchingConditionWithKeyword(marketTrans.addressTag.toLowerCase().trim(), + .add(countMatchingConditionWithKeyword(normalizeStringExpression(marketTrans.title), + keywords, 0)) + .add(countMatchingConditionWithKeyword(normalizeStringExpression(marketTrans.addressTag), keywords, 0)) .add(countMatchingConditionWithKeyword(marketTrans.content, keywords, 0)); } diff --git a/src/main/java/com/jeju/nanaland/domain/member/controller/MemberController.java b/src/main/java/com/jeju/nanaland/domain/member/controller/MemberController.java index 551d309d..3f0d1016 100644 --- a/src/main/java/com/jeju/nanaland/domain/member/controller/MemberController.java +++ b/src/main/java/com/jeju/nanaland/domain/member/controller/MemberController.java @@ -42,7 +42,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -50,9 +49,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @Slf4j @RestController @@ -75,12 +72,11 @@ public class MemberController { @ApiResponse(responseCode = "409", description = "이미 가입된 계정이 있는 경우, 닉네임이 중복되는 경우", content = @Content), @ApiResponse(responseCode = "500", description = "서버측 에러", content = @Content) }) - @PostMapping(value = "/join", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/join") public BaseResponse join( - @RequestPart(value = "reqDto") @Valid MemberRequest.JoinDto joinDto, - @RequestPart(required = false) MultipartFile multipartFile) { - JwtDto jwtDto = memberLoginService.join(joinDto, multipartFile); + @RequestBody @Valid MemberRequest.JoinDto joinDto, + @RequestParam(required = false) String fileKey) { + JwtDto jwtDto = memberLoginService.join(joinDto, fileKey); return BaseResponse.success(JOIN_SUCCESS, jwtDto); } @@ -220,6 +216,7 @@ public BaseResponse withdrawal( description = "유저 닉네임, 설명, 프로필 사진 수정") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "파일키 형식이 맞지 않는 등 입력값이 올바르지 않은 경우", content = @Content), @ApiResponse(responseCode = "401", description = "accessToken이 유효하지 않은 경우", content = @Content), @ApiResponse(responseCode = "500", description = "이미지 업로드에 실패한 경우", content = @Content) }) diff --git a/src/main/java/com/jeju/nanaland/domain/member/service/MemberLoginService.java b/src/main/java/com/jeju/nanaland/domain/member/service/MemberLoginService.java index f8e91ae0..626fbd23 100644 --- a/src/main/java/com/jeju/nanaland/domain/member/service/MemberLoginService.java +++ b/src/main/java/com/jeju/nanaland/domain/member/service/MemberLoginService.java @@ -9,7 +9,6 @@ import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.data.Status; import com.jeju.nanaland.domain.common.entity.ImageFile; -import com.jeju.nanaland.domain.common.service.FileService; import com.jeju.nanaland.domain.common.service.ImageFileService; import com.jeju.nanaland.domain.member.dto.MemberRequest; import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; @@ -26,8 +25,10 @@ import com.jeju.nanaland.global.exception.ConflictException; import com.jeju.nanaland.global.exception.NotFoundException; import com.jeju.nanaland.global.exception.UnauthorizedException; +import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.service.FileUploadService; +import com.jeju.nanaland.global.image_upload.dto.S3ImageDto; import com.jeju.nanaland.global.util.JwtUtil; -import java.io.File; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -36,7 +37,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -49,19 +49,19 @@ public class MemberLoginService { private final MemberConsentService memberConsentService; private final ImageFileService imageFileService; private final FcmTokenService fcmTokenService; - private final FileService fileService; private final MemberProfileService memberProfileService; + private final FileUploadService fileUploadService; /** * 회원 가입 * - * @param joinDto 회원 가입 정보 - * @param multipartFile 프로필 사진 + * @param joinDto 회원 가입 정보 + * @param fileKey 파일 키 * @return JWT * @throws ConflictException provider, providerId로 이미 가입된 회원이 존재하는 경우 */ @Transactional - public JwtDto join(MemberRequest.JoinDto joinDto, MultipartFile multipartFile) { + public JwtDto join(MemberRequest.JoinDto joinDto, String fileKey) { Optional savedMember = memberRepository.findByProviderAndProviderId( Provider.valueOf(joinDto.getProvider()), joinDto.getProviderId()); @@ -73,7 +73,7 @@ public JwtDto join(MemberRequest.JoinDto joinDto, MultipartFile multipartFile) { String nickname = determineNickname(joinDto); validateNickname(nickname); - ImageFile profileImageFile = memberProfileService.saveRandomProfileImageFile(); + ImageFile profileImageFile = createProfileImageFile(fileKey); Member member = createMember(joinDto, profileImageFile, nickname); // GUEST가 아닌 경우, 이용약관 저장 @@ -86,15 +86,26 @@ public JwtDto join(MemberRequest.JoinDto joinDto, MultipartFile multipartFile) { fcmTokenService.createFcmToken(member, joinDto.getFcmToken()); } - // 비동기 처리 - if (multipartFile != null && !multipartFile.isEmpty()) { - File convertedFile = fileService.convertMultipartFileToFile(multipartFile); - imageFileService.uploadMemberProfileImage(member.getId(), convertedFile); - } - return getJwtDto(member); } + /** + * 프로필 사진 업로드 및 저장. + * 프로필 사진이 없는 경우엔, 랜덤 프로필 사진 저장 + * + * @param fileKey 파일 키 + * @return 저장된 이미지 파일 또는 랜덤 프로필 사진 파일 + */ + private ImageFile createProfileImageFile(String fileKey) { + if (fileKey == null) { + return memberProfileService.saveRandomProfileImageFile(); + } + fileUploadService.validateFileExtension(fileKey, FileCategory.MEMBER_PROFILE); + S3ImageDto s3ImageDto = fileUploadService.getCloudImageUrls(fileKey); + return imageFileService.saveS3ImageFile(s3ImageDto); + } + + /** * 닉네임 설정. GUEST 유형의 경우 UUID를 사용하여 랜덤 닉네임 생성. GUEST가 아닌 경우, 제공된 닉네임을 반환. * @@ -202,7 +213,6 @@ private JwtDto getJwtDto(Member member) { * @param member 회원 * @throws NotFoundException 존재하는 회원 탈퇴 정보가 없는 경우 */ - @Transactional public void updateMemberActive(Member member) { if (member.getStatus().equals(Status.INACTIVE)) { member.updateStatus(Status.ACTIVE); @@ -219,7 +229,6 @@ public void updateMemberActive(Member member) { * @param loginDto 로그인 정보 * @param member 회원 */ - @Transactional public void updateLanguageDifferent(MemberRequest.LoginDto loginDto, Member member) { Language language = Language.valueOf(loginDto.getLocale()); if (!member.getLanguage().equals(language)) { @@ -316,7 +325,6 @@ public void withdrawal(MemberInfoDto memberInfoDto, MemberRequest.WithdrawalDto /** * 매일 0시 0분 0초에 실행되는 회원 탈퇴 스케줄러. 비활성화 후 3개월이 지난 회원 탈퇴 처리 */ - @Transactional @Scheduled(cron = "0 0 0 * * *") public void deleteWithdrawalMemberInfo() { List members = memberRepository.findAllInactiveMember(); diff --git a/src/main/java/com/jeju/nanaland/domain/member/service/MemberProfileService.java b/src/main/java/com/jeju/nanaland/domain/member/service/MemberProfileService.java index 71d80a79..3bb793a0 100644 --- a/src/main/java/com/jeju/nanaland/domain/member/service/MemberProfileService.java +++ b/src/main/java/com/jeju/nanaland/domain/member/service/MemberProfileService.java @@ -17,6 +17,7 @@ import com.jeju.nanaland.global.exception.ErrorCode; import com.jeju.nanaland.global.exception.NotFoundException; import com.jeju.nanaland.global.exception.ServerErrorException; +import com.jeju.nanaland.global.file.data.FileCategory; import com.jeju.nanaland.global.file.service.FileUploadService; import com.jeju.nanaland.global.image_upload.dto.S3ImageDto; import java.util.ArrayList; @@ -54,6 +55,7 @@ public void updateProfile(MemberInfoDto memberInfoDto, MemberRequest.ProfileUpda Member member = memberInfoDto.getMember(); validateNickname(profileUpdateDto.getNickname(), member); if (fileKey != null) { + fileUploadService.validateFileExtension(fileKey, FileCategory.MEMBER_PROFILE); S3ImageDto s3ImageDto = fileUploadService.getCloudImageUrls(fileKey); member.getProfileImageFile().updateImageFile(s3ImageDto.getOriginUrl(), s3ImageDto.getThumbnailUrl()); } diff --git a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java index c9c70282..e11a769b 100644 --- a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java @@ -385,6 +385,18 @@ private List splitKeyword(String keyword) { return tokenList; } + /** + * 공백 제거, 소문자화, '-'와 '_' 제거 + * + * @param stringExpression 조건절 컬럼 + * @return 정규화된 컬럼 + */ + private StringExpression normalizeStringExpression(StringExpression stringExpression) { + return Expressions.stringTemplate( + "replace(replace({0}, '-', ''), '_', '')", + stringExpression.toLowerCase().trim()); + } + /** * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 * @@ -393,8 +405,8 @@ private List splitKeyword(String keyword) { */ private Expression getMaxMatchingCountWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(nanaTitle.heading.toLowerCase().trim(), keywords, - 0)) + .add(countMatchingConditionWithKeyword(normalizeStringExpression(nanaTitle.heading), + keywords, 0)) .add(countMatchingConditionWithKeyword(nanaContent.content, keywords, 0)) .max(); } diff --git a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java index bacaf5c8..a485439b 100644 --- a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java @@ -439,6 +439,18 @@ private List splitKeyword(String keyword) { return tokenList; } + /** + * 공백 제거, 소문자화, '-'와 '_' 제거 + * + * @param stringExpression 조건절 컬럼 + * @return 정규화된 컬럼 + */ + private StringExpression normalizeStringExpression(StringExpression stringExpression) { + return Expressions.stringTemplate( + "replace(replace({0}, '-', ''), '_', '')", + stringExpression.toLowerCase().trim()); + } + /** * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 * @@ -447,9 +459,9 @@ private List splitKeyword(String keyword) { */ private Expression countMatchingWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(natureTrans.title.toLowerCase().trim(), keywords, - 0)) - .add(countMatchingConditionWithKeyword(natureTrans.addressTag.toLowerCase().trim(), + .add(countMatchingConditionWithKeyword(normalizeStringExpression(natureTrans.title), + keywords, 0)) + .add(countMatchingConditionWithKeyword(normalizeStringExpression(natureTrans.addressTag), keywords, 0)) .add(countMatchingConditionWithKeyword(natureTrans.content, keywords, 0)); } diff --git a/src/main/java/com/jeju/nanaland/domain/report/controller/ReportController.java b/src/main/java/com/jeju/nanaland/domain/report/controller/ReportController.java index ab9675dd..9c934963 100644 --- a/src/main/java/com/jeju/nanaland/domain/report/controller/ReportController.java +++ b/src/main/java/com/jeju/nanaland/domain/report/controller/ReportController.java @@ -9,21 +9,17 @@ import com.jeju.nanaland.global.BaseResponse; import com.jeju.nanaland.global.auth.AuthMember; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; 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 @RequiredArgsConstructor @@ -37,43 +33,31 @@ public class ReportController { @Operation(summary = "정보 수정 제안", description = "게시물 id와 카테고리를 통해 게시물 정보 수정 제안 요청") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (이메일 형식 오류, category로 NANA 요청)", content = @Content), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (이메일 형식 오류, category로 NANA 요청, 파일키 형식 오류)", content = @Content), @ApiResponse(responseCode = "404", description = "해당 게시물이 없는 경우", content = @Content), @ApiResponse(responseCode = "500", description = "사진파일 업로드 실패 또는 관리자에게로 메일 전송 실패", content = @Content) }) - @PostMapping(value = "/info-fix", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/info-fix") public BaseResponse requestPostInfoFix( @AuthMember MemberInfoDto memberInfoDto, - @RequestPart("reqDto") @Valid ReportRequest.InfoFixDto reqDto, - @Parameter( - description = "정보 수정 요청 이미지파일 리스트", - content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) - ) - @RequestPart(value = "multipartFileList", required = false) List imageList) { + @RequestBody @Valid ReportRequest.InfoFixDto reqDto) { - reportService.requestPostInfoFix(memberInfoDto, reqDto, imageList); + reportService.requestPostInfoFix(memberInfoDto, reqDto); return BaseResponse.success(POST_INFO_FIX_REPORT_SUCCESS); } @Operation(summary = "신고 기능") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청인 경우", content = @Content), + @ApiResponse(responseCode = "400", description = "파일키 형식이 맞지 않는 등 입력값이 올바르지 않은 경우", content = @Content), @ApiResponse(responseCode = "404", description = "해당 게시물이 없는 경우", content = @Content), @ApiResponse(responseCode = "500", description = "사진파일 업로드 실패 또는 관리자에게로 메일 전송 실패", content = @Content) }) - @PostMapping(value = "/claim", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/claim") public BaseResponse requestClaimReport( @AuthMember MemberInfoDto memberInfoDto, - @RequestPart("reqDto") @Valid ReportRequest.ClaimReportDto reqDto, - @Parameter( - description = "신고 요청 파일 리스트", - content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) - ) - @RequestPart(value = "multipartFileList", required = false) List fileList) { - reportService.requestClaimReport(memberInfoDto, reqDto, fileList); + @RequestBody @Valid ReportRequest.ClaimReportDto reqDto) { + reportService.requestClaimReport(memberInfoDto, reqDto); return BaseResponse.success(POST_REVIEW_REPORT_SUCCESS); } } diff --git a/src/main/java/com/jeju/nanaland/domain/report/dto/ReportRequest.java b/src/main/java/com/jeju/nanaland/domain/report/dto/ReportRequest.java index 7bfcde20..32afd0d5 100644 --- a/src/main/java/com/jeju/nanaland/domain/report/dto/ReportRequest.java +++ b/src/main/java/com/jeju/nanaland/domain/report/dto/ReportRequest.java @@ -10,6 +10,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -61,6 +62,9 @@ public static class InfoFixDto { example = "test@naver.com" ) private String email; + + @Schema(description = "파일 키 리스트", example = "[\"test/fileKey1.jpg\", \"test/fileKey2.jpeg\", \"test/fileKey3.png\"]") + private List fileKeys; } @Data @@ -110,5 +114,8 @@ public static class ClaimReportDto { example = "test@naver.com" ) private String email; + + @Schema(description = "파일 키 리스트", example = "[\"test/fileKey1.jpg\", \"test/fileKey2.jpeg\", \"test/fileKey3.png\"]") + private List fileKeys; } } diff --git a/src/main/java/com/jeju/nanaland/domain/report/entity/claim/ClaimReportVideoFile.java b/src/main/java/com/jeju/nanaland/domain/report/entity/claim/ClaimReportVideoFile.java index c5abfafc..43316728 100644 --- a/src/main/java/com/jeju/nanaland/domain/report/entity/claim/ClaimReportVideoFile.java +++ b/src/main/java/com/jeju/nanaland/domain/report/entity/claim/ClaimReportVideoFile.java @@ -28,7 +28,7 @@ public class ClaimReportVideoFile { private Long id; @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) - @JoinColumn(name = "image_file_id", nullable = false) + @JoinColumn(name = "video_file_id", nullable = false) private VideoFile videoFile; @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) diff --git a/src/main/java/com/jeju/nanaland/domain/report/service/ReportService.java b/src/main/java/com/jeju/nanaland/domain/report/service/ReportService.java index 9cdd6a2f..dfa80e81 100644 --- a/src/main/java/com/jeju/nanaland/domain/report/service/ReportService.java +++ b/src/main/java/com/jeju/nanaland/domain/report/service/ReportService.java @@ -1,7 +1,6 @@ package com.jeju.nanaland.domain.report.service; import static com.jeju.nanaland.global.exception.ErrorCode.ALREADY_REPORTED; -import static com.jeju.nanaland.global.exception.ErrorCode.IMAGE_BAD_REQUEST; import static com.jeju.nanaland.global.exception.ErrorCode.MEMBER_NOT_FOUND; import static com.jeju.nanaland.global.exception.ErrorCode.NANA_INFO_FIX_FORBIDDEN; import static com.jeju.nanaland.global.exception.ErrorCode.NOT_FOUND_EXCEPTION; @@ -13,7 +12,6 @@ import com.jeju.nanaland.domain.common.dto.CompositeDto; import com.jeju.nanaland.domain.common.entity.ImageFile; import com.jeju.nanaland.domain.common.entity.VideoFile; -import com.jeju.nanaland.domain.common.service.FileService; import com.jeju.nanaland.domain.common.service.ImageFileService; import com.jeju.nanaland.domain.common.service.MailService; import com.jeju.nanaland.domain.common.service.VideoFileService; @@ -43,26 +41,22 @@ import com.jeju.nanaland.domain.review.repository.ReviewRepository; import com.jeju.nanaland.global.exception.BadRequestException; import com.jeju.nanaland.global.exception.NotFoundException; -import java.io.File; +import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.service.FileUploadService; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @Slf4j public class ReportService { - private static final int MAX_IMAGE_COUNT = 5; private final MemberRepository memberRepository; private final ClaimReportVideoFileRepository claimReportVideoFileRepository; private final ClaimReportRepository claimReportRepository; @@ -77,25 +71,19 @@ public class ReportService { private final VideoFileService videoFileService; private final MailService mailService; private final ReportStrategyFactory reportStrategyFactory; - private final FileService fileService; - @Value("${cloud.aws.s3.infoFixReportImageDirectory}") - private String INFO_FIX_REPORT_IMAGE_DIRECTORY; - @Value("${cloud.aws.s3.claimReportFileDirectory}") - private String CLAIM_REPORT_FILE_DIRECTORY; + private final FileUploadService fileUploadService; /** * 정보 수정 제안 * * @param memberInfoDto 회원 정보 * @param reqDto 수정 요청 DTO - * @param files 수정 요청 이미지 파일 리스트 * @throws NotFoundException 존재하는 게시물이 없는 경우 */ @Transactional - public void requestPostInfoFix(MemberInfoDto memberInfoDto, ReportRequest.InfoFixDto reqDto, - List files) { + public void requestPostInfoFix(MemberInfoDto memberInfoDto, ReportRequest.InfoFixDto reqDto) { // 수정 요청 유효성 검사 - validateInfoFixReportRequest(reqDto, files); + validateInfoFixReportRequest(reqDto); // 해당 게시물 정보 가져오기 CompositeDto compositeDto = findCompositeDto(Category.valueOf(reqDto.getCategory()), @@ -118,40 +106,26 @@ public void requestPostInfoFix(MemberInfoDto memberInfoDto, ReportRequest.InfoFi infoFixReportRepository.save(infoFixReport); // 이미지 저장 - CompletableFuture> futureImageUrls = saveImagesAndGetUrls(files, infoFixReport, - INFO_FIX_REPORT_IMAGE_DIRECTORY); + List imageUrls = saveImagesAndGetUrls(reqDto.getFileKeys(), infoFixReport); // 이메일 전송 - futureImageUrls.thenAccept(imageUrls -> mailService.sendEmailReport(infoFixReport, imageUrls)); + mailService.sendEmailReport(infoFixReport, imageUrls); } /** * 정보 수정 제안 요청 유효성 확인 * * @param reqDto 수정 요청 DTO - * @param files 수정 요청 이미지 파일 리스트 * @throws BadRequestException 카테고리가 올바르지 않은 경우 */ - private void validateInfoFixReportRequest(InfoFixDto reqDto, List files) { + private void validateInfoFixReportRequest(InfoFixDto reqDto) { Category category = Category.valueOf(reqDto.getCategory()); // 나나스픽 전처리 if (List.of(Category.NANA, Category.NANA_CONTENT).contains(category)) { throw new BadRequestException(NANA_INFO_FIX_FORBIDDEN.getMessage()); } - checkFileCountLimit(files); - } - - /** - * 파일 개수 유효성 확인 - * - * @param files 파일 리스트 - * @throws BadRequestException 파일 개수가 초과된 경우 - */ - private void checkFileCountLimit(List files) { - if (files != null && files.size() > MAX_IMAGE_COUNT) { - throw new BadRequestException(IMAGE_BAD_REQUEST.getMessage()); - } + fileUploadService.validateFileKeys(reqDto.getFileKeys(), FileCategory.INFO_FIX_REPORT); } /** @@ -178,13 +152,11 @@ private CompositeDto findCompositeDto(Category category, Long postId, Language l * * @param memberInfoDto 회원 정보 * @param reqDto 신고 요청 DTO - * @param files 파일 리스트 */ @Transactional - public void requestClaimReport(MemberInfoDto memberInfoDto, ReportRequest.ClaimReportDto reqDto, - List files) { + public void requestClaimReport(MemberInfoDto memberInfoDto, ReportRequest.ClaimReportDto reqDto) { // 요청 유효성 확인 - validateClaimReportRequest(memberInfoDto, reqDto, files); + validateClaimReportRequest(memberInfoDto, reqDto); // claimReport 저장 ClaimReport claimReport = ClaimReport.builder() @@ -198,22 +170,15 @@ public void requestClaimReport(MemberInfoDto memberInfoDto, ReportRequest.ClaimR claimReportRepository.save(claimReport); // 이미지, 동영상 저장 - List imageFiles = filterFilesByType(files, "image/"); - List videoFiles = filterFilesByType(files, "video/"); + List imageFileKeys = filterFilesByType(reqDto.getFileKeys(), true); + List videoFileKeys = filterFilesByType(reqDto.getFileKeys(), false); - CompletableFuture> futureImageUrls = saveImagesAndGetUrls(imageFiles, claimReport, - CLAIM_REPORT_FILE_DIRECTORY); - CompletableFuture> futureVideoUrls = saveVideosAndGetUrls(videoFiles, claimReport); + List imageUrls = saveImagesAndGetUrls(imageFileKeys, claimReport); + List videoUrls = saveVideosAndGetUrls(videoFileKeys, claimReport); - CompletableFuture.allOf(futureImageUrls, futureVideoUrls) - .thenApply(v -> { - List imageUrls = futureImageUrls.join(); - List videoUrls = futureVideoUrls.join(); - List combinedUrls = new ArrayList<>(imageUrls); - combinedUrls.addAll(videoUrls); - return combinedUrls; - }) - .thenAccept(combinedUrls -> mailService.sendEmailReport(claimReport, combinedUrls)); + List combinedUrls = new ArrayList<>(imageUrls); + combinedUrls.addAll(videoUrls); + mailService.sendEmailReport(claimReport, combinedUrls); } /** @@ -221,11 +186,9 @@ public void requestClaimReport(MemberInfoDto memberInfoDto, ReportRequest.ClaimR * * @param memberInfoDto 회원 정보 * @param reqDto 신고 요청 DTO - * @param files 파일 리스트 * @throws BadRequestException 이미 신고한 적이 있는 경우 */ - private void validateClaimReportRequest(MemberInfoDto memberInfoDto, ClaimReportDto reqDto, - List files) { + private void validateClaimReportRequest(MemberInfoDto memberInfoDto, ClaimReportDto reqDto) { // 타입별 유효성 확인 ClaimReportType claimReportType = ClaimReportType.valueOf(reqDto.getReportType()); if (claimReportType == ClaimReportType.REVIEW) { @@ -241,7 +204,7 @@ private void validateClaimReportRequest(MemberInfoDto memberInfoDto, ClaimReport throw new BadRequestException(ALREADY_REPORTED.getMessage()); } - checkFileCountLimit(files); + fileUploadService.validateFileKeys(reqDto.getFileKeys(), FileCategory.CLAIM_REPORT); } /** @@ -283,96 +246,67 @@ private void validateMemberReportRequest(MemberInfoDto memberInfoDto, /** * 타입별 파일 필터링 * - * @param files 파일 리스트 - * @param type 파일 타입 + * @param fileKeys 파일 키 리스트 + * @param isImage 파일 타입 (이미지이면 true, 영상이면 false) * @return 필터링된 파일 리스트 */ - private List filterFilesByType(List files, String type) { - if (files == null) { + private List filterFilesByType(List fileKeys, boolean isImage) { + if (fileKeys == null) { return new ArrayList<>(); } - return files.stream() - .filter(file -> file.getContentType() != null && file.getContentType().startsWith(type)) + return fileKeys.stream() + .filter(fileKey -> + (isImage && FileCategory.CLAIM_REPORT.isImage(fileKey)) + || (!isImage && FileCategory.CLAIM_REPORT.isVideo(fileKey))) .collect(Collectors.toList()); } /** * 이미지 파일 저장, 이미지 URL 리스트 얻기 * - * @param multipartFiles 이미지 파일 리스트 + * @param fileKeys 파일 키 리스트 * @param report 요청 (InfoFixReport, ClaimReport) - * @param directory 파일 저장 위치 * @return 이미지 URL 리스트 */ - private CompletableFuture> saveImagesAndGetUrls(List multipartFiles, Report report, - String directory) { - if (multipartFiles == null || multipartFiles.isEmpty()) { - return CompletableFuture.completedFuture(Collections.emptyList()); + private List saveImagesAndGetUrls(List fileKeys, Report report) { + if (fileKeys == null || fileKeys.isEmpty()) { + return new ArrayList<>(); } // 이미지 저장 - List> futureImageFiles = multipartFiles.stream() - .map(multipartFile -> { - File file = fileService.convertMultipartFileToFile(multipartFile); - return CompletableFuture.supplyAsync(() -> - imageFileService.uploadAndSaveImageFile(file, false, directory)); - }) + List imageFiles = fileKeys.stream() + .map(imageFileService::getAndSaveImageFile) .toList(); - return CompletableFuture.allOf(futureImageFiles.toArray(new CompletableFuture[0])) - .thenApply(v -> { - // ImageFile 리스트로 변환 - List imageFiles = futureImageFiles.stream() - .map(CompletableFuture::join) - .collect(Collectors.toList()); - - // 이미지와 Report 매핑 저장 - ReportStrategy reportStrategy = reportStrategyFactory.findStrategy(report.getReportType()); - reportStrategy.saveReportImages(report, imageFiles); - - // 이미지 URL 리스트 반환 - return imageFiles.stream() - .map(ImageFile::getOriginUrl) - .collect(Collectors.toList()); - }); + ReportStrategy reportStrategy = reportStrategyFactory.findStrategy(report.getReportType()); + reportStrategy.saveReportImages(report, imageFiles); + return imageFiles.stream() + .map(ImageFile::getOriginUrl) + .collect(Collectors.toList()); } /** * 동영상 파일 저장, 동영상 URL 리스트 얻기 * - * @param multipartFiles 동영상 파일 리스트 + * @param fileKeys 동영상 파일 키 리스트 * @param report 요청 (InfoFixReport, ClaimReport) * @return 동영상 URL 리스트 */ - private CompletableFuture> saveVideosAndGetUrls(List multipartFiles, ClaimReport report) { - if (multipartFiles == null || multipartFiles.isEmpty()) { - return CompletableFuture.completedFuture(Collections.emptyList()); + private List saveVideosAndGetUrls(List fileKeys, ClaimReport report) { + if (fileKeys == null || fileKeys.isEmpty()) { + return new ArrayList<>(); } // 동영상 파일 저장 - List> futureVideoFiles = multipartFiles.stream() - .map(multipartFile -> { - File file = fileService.convertMultipartFileToFile(multipartFile); - return CompletableFuture.supplyAsync(() -> - videoFileService.uploadAndSaveVideoFile(file, CLAIM_REPORT_FILE_DIRECTORY)); - }) + List videoFiles = fileKeys.stream() + .map(videoFileService::getAndSaveVideoFile) .toList(); + List reportVideoFiles = createReportVideoFiles(videoFiles, report); + claimReportVideoFileRepository.saveAll(reportVideoFiles); - return CompletableFuture.allOf(futureVideoFiles.toArray(new CompletableFuture[0])) - .thenApply(v -> { - // VideoFile 리스트로 변환 - List videoFiles = futureVideoFiles.stream() - .map(CompletableFuture::join) - .toList(); - - // 동영상과 Report 매핑 생성 및 저장 - List reportVideoFiles = createReportVideoFiles(videoFiles, report); - claimReportVideoFileRepository.saveAll(reportVideoFiles); - - // 이미지 URL 리스트 반환 - return videoFiles.stream() - .map(VideoFile::getOriginUrl) - .collect(Collectors.toList()); - }); + // 동영상 URL 리스트 반환 + return videoFiles.stream() + .map(VideoFile::getOriginUrl) + .collect(Collectors.toList()); } /** diff --git a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java index 09760f93..36cbfa8a 100644 --- a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java @@ -494,6 +494,18 @@ public List findAllIds() { .fetch(); } + /** + * 공백 제거, 소문자화, '-'와 '_' 제거 + * + * @param stringExpression 조건절 컬럼 + * @return 정규화된 컬럼 + */ + private StringExpression normalizeStringExpression(StringExpression stringExpression) { + return Expressions.stringTemplate( + "replace(replace({0}, '-', ''), '_', '')", + stringExpression.toLowerCase().trim()); + } + /** * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 * @@ -502,10 +514,11 @@ public List findAllIds() { */ private Expression countMatchingWithKeyword(List keywords) { return Expressions.asNumber(0L) - .add(countMatchingConditionWithKeyword(restaurantTrans.title.toLowerCase().trim(), keywords, - 0)) - .add(countMatchingConditionWithKeyword(restaurantTrans.addressTag.toLowerCase().trim(), + .add(countMatchingConditionWithKeyword(normalizeStringExpression(restaurantTrans.title), keywords, 0)) + .add( + countMatchingConditionWithKeyword(normalizeStringExpression(restaurantTrans.addressTag), + keywords, 0)) .add(countMatchingConditionWithKeyword(restaurantTrans.content, keywords, 0)); } @@ -515,7 +528,7 @@ private Expression countMatchingWithKeyword(List keywords) { * @param condition 테이블 컬럼 * @param keywords 유저 키워드 리스트 * @param idx 키워드 인덱스 - * @return + * @return 매칭된 수 */ private Expression countMatchingConditionWithKeyword(StringExpression condition, List keywords, int idx) { diff --git a/src/main/java/com/jeju/nanaland/domain/review/controller/ReviewController.java b/src/main/java/com/jeju/nanaland/domain/review/controller/ReviewController.java index 4c15608f..4fcb61b0 100644 --- a/src/main/java/com/jeju/nanaland/domain/review/controller/ReviewController.java +++ b/src/main/java/com/jeju/nanaland/domain/review/controller/ReviewController.java @@ -27,19 +27,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; -import java.util.concurrent.ExecutionException; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -70,19 +67,19 @@ public BaseResponse getReviewList( @Operation(summary = "리뷰 생성", description = "게시물에 대한 리뷰 작성") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "파일키 형식이 맞지 않는 등 입력값이 올바르지 않은 경우", content = @Content), @ApiResponse(responseCode = "401", description = "accessToken이 유효하지 않은 경우", content = @Content), + @ApiResponse(responseCode = "404", description = "입력한 값이 존재하지 않는 경우", content = @Content), @ApiResponse(responseCode = "500", description = "서버측 에러", content = @Content) }) - @PostMapping(value = "/{id}", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = "/{id}") public BaseResponse saveReview( @AuthMember MemberInfoDto memberInfoDto, @PathVariable Long id, @RequestParam Category category, - @RequestPart(value = "multipartFileList", required = false) List imageList, - @RequestPart @Valid ReviewRequest.CreateReviewDto createReviewDto + @RequestBody @Valid ReviewRequest.CreateReviewDto reqDto ) { - reviewService.saveReview(memberInfoDto, id, category, createReviewDto, imageList); + reviewService.saveReview(memberInfoDto, id, category, reqDto); return BaseResponse.success(REVIEW_CREATED_SUCCESS); } @@ -154,14 +151,12 @@ public BaseResponse getMyReviewDetail( @ApiResponse(responseCode = "401", description = "accessToken이 유효하지 않은 경우", content = @Content), @ApiResponse(responseCode = "404", description = "존재하지 않는 데이터인 경우", content = @Content) }) - @PutMapping(value = "/my/{id}", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PutMapping(value = "/my/{id}") public BaseResponse updateMyReview( @AuthMember MemberInfoDto memberInfoDto, @PathVariable Long id, - @RequestPart(required = false) List imageList, - @RequestPart @Valid ReviewRequest.EditReviewDto editReviewDto) { - reviewService.updateMyReview(memberInfoDto, id, imageList, editReviewDto); + @RequestBody @Valid ReviewRequest.EditReviewDto reqDto) { + reviewService.updateMyReview(memberInfoDto, id, reqDto); return BaseResponse.success(REVIEW_UPDATE_SUCCESS); } @@ -188,7 +183,7 @@ public BaseResponse deleteMyReview( @GetMapping("/search/auto-complete") public BaseResponse> getAutoCompleteSearchResultForReview( @AuthMember MemberInfoDto memberInfoDto, - @RequestParam String keyword) throws ExecutionException, InterruptedException { + @RequestParam String keyword) { return BaseResponse.success(REVIEW_SEARCH_AUTO_COMPLETE_SUCCESS, reviewService.getAutoCompleteSearchResultForReview( memberInfoDto, keyword)); diff --git a/src/main/java/com/jeju/nanaland/domain/review/dto/ReviewRequest.java b/src/main/java/com/jeju/nanaland/domain/review/dto/ReviewRequest.java index a33aaea6..e26729b1 100644 --- a/src/main/java/com/jeju/nanaland/domain/review/dto/ReviewRequest.java +++ b/src/main/java/com/jeju/nanaland/domain/review/dto/ReviewRequest.java @@ -43,6 +43,8 @@ public static class CreateReviewDto { @Size(max = 6, message = "최대 6개까지 선택 가능합니다") private List reviewKeywords; + @Schema(description = "파일 키 리스트", example = "[\"test/fileKey1.jpg\", \"test/fileKey2.jpeg\", \"test/fileKey3.png\"]") + private List fileKeys; } @Getter @@ -74,11 +76,13 @@ public static class EditReviewDto { @Size(max = 6, message = "최대 6개까지 선택 가능합니다") private List reviewKeywords; - @Schema(description = "리뷰 이미지 수정 정보 리스트 => List와 newImage = true인 것과 수 같아야 함 /" + @Schema(description = "리뷰 이미지 수정 정보 리스트 => fileKeys와 newImage = true인 것과 수 같아야 함 /" + " 수정되어 제출되는 리뷰에 이미지가 없다면 null이 아닌 빈 리스트 [] 를 보내야 합니다.") @NotNull private List editImageInfoList; + @Schema(description = "파일 키 리스트", example = "[\"test/fileKey1.jpg\", \"test/fileKey2.jpeg\", \"test/fileKey3.png\"]") + private List fileKeys; @Getter @Builder @@ -90,7 +94,6 @@ public static class EditImageInfoDto { @Schema(description = "이미지 수정 여부 / 존재하던 사진이면 false, 새로 추가된 사진이면 true") private boolean newImage; - } } diff --git a/src/main/java/com/jeju/nanaland/domain/review/entity/ReviewImageFile.java b/src/main/java/com/jeju/nanaland/domain/review/entity/ReviewImageFile.java index 27570fd4..f951ec4b 100644 --- a/src/main/java/com/jeju/nanaland/domain/review/entity/ReviewImageFile.java +++ b/src/main/java/com/jeju/nanaland/domain/review/entity/ReviewImageFile.java @@ -18,7 +18,6 @@ @Entity @Getter -@Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ReviewImageFile { diff --git a/src/main/java/com/jeju/nanaland/domain/review/service/ReviewService.java b/src/main/java/com/jeju/nanaland/domain/review/service/ReviewService.java index 42170d8b..058639e0 100644 --- a/src/main/java/com/jeju/nanaland/domain/review/service/ReviewService.java +++ b/src/main/java/com/jeju/nanaland/domain/review/service/ReviewService.java @@ -5,7 +5,6 @@ import static com.jeju.nanaland.global.exception.ErrorCode.MEMBER_REVIEW_NOT_FOUND; import static com.jeju.nanaland.global.exception.ErrorCode.NOT_FOUND_EXCEPTION; import static com.jeju.nanaland.global.exception.ErrorCode.NOT_MY_REVIEW; -import static com.jeju.nanaland.global.exception.ErrorCode.REVIEW_IMAGE_BAD_REQUEST; import static com.jeju.nanaland.global.exception.ErrorCode.REVIEW_IMAGE_IMAGE_INFO_NOT_MATCH; import static com.jeju.nanaland.global.exception.ErrorCode.REVIEW_INVALID_CATEGORY; import static com.jeju.nanaland.global.exception.ErrorCode.REVIEW_KEYWORD_DUPLICATION; @@ -16,7 +15,6 @@ import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.entity.ImageFile; import com.jeju.nanaland.domain.common.entity.Post; -import com.jeju.nanaland.domain.common.service.FileService; import com.jeju.nanaland.domain.common.service.ImageFileService; import com.jeju.nanaland.domain.experience.repository.ExperienceRepository; import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; @@ -48,8 +46,9 @@ import com.jeju.nanaland.global.exception.BadRequestException; import com.jeju.nanaland.global.exception.ErrorCode; import com.jeju.nanaland.global.exception.NotFoundException; +import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.service.FileUploadService; import jakarta.annotation.PostConstruct; -import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -58,7 +57,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -70,7 +68,6 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @@ -78,7 +75,6 @@ public class ReviewService { private static final String SEARCH_AUTO_COMPLETE_HASH_KEY = "REVIEW AUTO COMPLETE:"; - private final FileService fileService; private final ReviewRepository reviewRepository; private final ExperienceRepository experienceRepository; private final ReviewKeywordRepository reviewKeywordRepository; @@ -88,6 +84,7 @@ public class ReviewService { private final RedisTemplate redisTemplate; private final MemberRepository memberRepository; private final RestaurantRepository restaurantRepository; + private final FileUploadService fileUploadService; @Value("${cloud.aws.s3.reviewDirectory}") private String reviewImageDirectoryPath; @@ -115,15 +112,10 @@ public ReviewListDto getReviewList(MemberInfoDto memberInfoDto, Category categor // 리뷰 생성 @Transactional public void saveReview(MemberInfoDto memberInfoDto, Long id, Category category, - CreateReviewDto createReviewDto, List multipartFiles) { - if (category != Category.EXPERIENCE && category != Category.RESTAURANT) { - throw new BadRequestException(REVIEW_INVALID_CATEGORY.getMessage()); - } + CreateReviewDto createReviewDto) { + validateReviewRequest(category, createReviewDto); Post post = getPostById(id, category); - if (multipartFiles != null && multipartFiles.size() > 5) { - throw new BadRequestException(REVIEW_IMAGE_BAD_REQUEST.getMessage()); - } // 리뷰 저장 Review review = reviewRepository.save(Review.builder() @@ -150,31 +142,29 @@ public void saveReview(MemberInfoDto memberInfoDto, Long id, Category category, .build())); // reviewImageFile - if (multipartFiles != null) { - List> futureImageFiles = multipartFiles.stream() - .map(multipartFile -> CompletableFuture.supplyAsync(() -> { - File file = fileService.convertMultipartFileToFile(multipartFile); - return imageFileService.uploadAndSaveImageFile(file, true, reviewImageDirectoryPath); - })) - .toList(); - - CompletableFuture.allOf(futureImageFiles.toArray(new CompletableFuture[0])) - .thenAccept(v -> { - // ImageFile 리스트로 변환 - List imageFiles = futureImageFiles.stream() - .map(CompletableFuture::join) - .toList(); - List reviewImageFiles = imageFiles.stream() - .map(imageFile -> ReviewImageFile.builder() - .imageFile(imageFile) - .review(review) - .build()) - .toList(); - reviewImageFileRepository.saveAll(reviewImageFiles); - }); + List fileKeys = createReviewDto.getFileKeys(); + if (fileKeys != null && !fileKeys.isEmpty()) { + List reviewImageFiles = fileKeys.stream() + .map((fileKey -> { + ImageFile imageFile = imageFileService.getAndSaveImageFile(fileKey); + return ReviewImageFile.builder() + .review(review) + .imageFile(imageFile) + .build(); + })).toList(); + + reviewImageFileRepository.saveAll(reviewImageFiles); } } + private void validateReviewRequest(Category category, CreateReviewDto createReviewDto) { + + if (category != Category.EXPERIENCE && category != Category.RESTAURANT) { + throw new BadRequestException(REVIEW_INVALID_CATEGORY.getMessage()); + } + fileUploadService.validateFileKeys(createReviewDto.getFileKeys(), FileCategory.REVIEW); + } + // 리뷰를 위한 게시글 검색 자동완성 public List getAutoCompleteSearchResultForReview( @@ -315,8 +305,7 @@ public void deleteMyReview(MemberInfoDto memberInfoDto, Long reviewId) { // 내가 쓴 리뷰 수정 @Transactional - public void updateMyReview(MemberInfoDto memberInfoDto, Long reviewId, - List imageList, EditReviewDto editReviewDto) { + public void updateMyReview(MemberInfoDto memberInfoDto, Long reviewId, EditReviewDto editReviewDto) { // 유저가 쓴 리뷰 조회 Review review = reviewRepository.findReviewByIdAndMember(reviewId, memberInfoDto.getMember()) .orElseThrow(() -> new NotFoundException(MEMBER_REVIEW_NOT_FOUND.getMessage())); @@ -334,7 +323,7 @@ public void updateMyReview(MemberInfoDto memberInfoDto, Long reviewId, // reviewKeyword 수정 updateReviewKeyword(review, editReviewDto); // 이미지가 있는 리뷰라면 수정 - updateReviewImages(review, editReviewDto, imageList); + updateReviewImages(review, editReviewDto, editReviewDto.getFileKeys()); } // 회원 별 리뷰 리스트 조회 @@ -459,7 +448,7 @@ private void updateReviewKeyword(Review review, EditReviewDto editReviewDto) { } private void updateReviewImages(Review review, EditReviewDto editReviewDto, - List editImages) { + List fileKeys) { List editImageInfoList = editReviewDto.getEditImageInfoList(); List originReviewImageList = reviewImageFileRepository.findAllByReview(review); @@ -470,8 +459,8 @@ private void updateReviewImages(Review review, EditReviewDto editReviewDto, .count(); // 수정된 리뷰에 이미지가 있을 경우 - // MultipartFile 이미지 리스트의 크기와 editImageInfo의 newImage가 true인 것의 수가 같은지 비교 - if ((editImages != null) && (totalNewImage != editImages.size())) { + // fileKeys의 크기와 editImageInfo의 newImage가 true인 것의 수가 같은지 비교 + if ((fileKeys != null) && (totalNewImage != fileKeys.size())) { throw new BadRequestException(REVIEW_IMAGE_IMAGE_INFO_NOT_MATCH.getMessage()); } @@ -482,36 +471,25 @@ private void updateReviewImages(Review review, EditReviewDto editReviewDto, .map(ReviewImageFile::getId) .collect(Collectors.toSet()); - List> futureReviewImageFiles = new ArrayList<>(); + List reviewImageFiles = new ArrayList<>(); int newImageIdx = 0; for (EditImageInfoDto editImageInfo : editImageInfoList) { // 수정 제출된 이미지가 if (editImageInfo.isNewImage()) { // 새로 제출된 이미지라면 저장 - final int currentIndex = newImageIdx++; - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - MultipartFile multipartFile = editImages.get(currentIndex); - File file = fileService.convertMultipartFileToFile(multipartFile); - ImageFile imageFile = imageFileService.uploadAndSaveImageFile(file, true, - reviewImageDirectoryPath); - return ReviewImageFile.builder() - .imageFile(imageFile) - .review(review) - .build(); - }); - futureReviewImageFiles.add(future); + assert fileKeys != null; + ImageFile imageFile = imageFileService.getAndSaveImageFile(fileKeys.get(newImageIdx++)); + ReviewImageFile reviewImageFile = ReviewImageFile.builder() + .imageFile(imageFile) + .review(review) + .build(); + reviewImageFiles.add(reviewImageFile); } else { // 원래 있던 이미지라면 if (!existImageIds.remove(editImageInfo.getId())) { // set에서 제거하기 , 제거가 안되었다면 imageInfo 잘못 준것 / 나중에 여기 남아있는 건 삭제해야한다고 판단. throw new BadRequestException(EDIT_REVIEW_IMAGE_INFO_BAD_REQUEST.getMessage()); } } } - - CompletableFuture.allOf(futureReviewImageFiles.toArray(new CompletableFuture[0])) - .thenApply(v -> futureReviewImageFiles.stream() - .map(CompletableFuture::join) - .collect(Collectors.toList())) - .thenAccept(reviewImageFileRepository::saveAll) - .join(); + reviewImageFileRepository.saveAll(reviewImageFiles); // 삭제되어야할 reviewImageFile List allById = reviewImageFileRepository.findAllById(existImageIds); diff --git a/src/main/java/com/jeju/nanaland/domain/search/controller/SearchController.java b/src/main/java/com/jeju/nanaland/domain/search/controller/SearchController.java index a8e94545..0dfc5861 100644 --- a/src/main/java/com/jeju/nanaland/domain/search/controller/SearchController.java +++ b/src/main/java/com/jeju/nanaland/domain/search/controller/SearchController.java @@ -2,6 +2,7 @@ import static com.jeju.nanaland.global.exception.SuccessCode.SEARCH_SUCCESS; +import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType; import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; import com.jeju.nanaland.domain.search.dto.SearchResponse; import com.jeju.nanaland.domain.search.dto.SearchResponse.AllCategoryDto; @@ -95,12 +96,13 @@ public BaseResponse searchFestival( @GetMapping("/experience") public BaseResponse searchExperience( @AuthMember MemberInfoDto memberInfoDto, + @RequestParam ExperienceType experienceType, @NotNull String keyword, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size) { - ResultDto result = searchService.searchExperience(memberInfoDto, keyword, page, - size); + ResultDto result = searchService.searchExperience(memberInfoDto, experienceType, keyword, + page, size); return BaseResponse.success(SEARCH_SUCCESS, result); } diff --git a/src/main/java/com/jeju/nanaland/domain/search/dto/SearchResponse.java b/src/main/java/com/jeju/nanaland/domain/search/dto/SearchResponse.java index ed60ef83..038addc7 100644 --- a/src/main/java/com/jeju/nanaland/domain/search/dto/SearchResponse.java +++ b/src/main/java/com/jeju/nanaland/domain/search/dto/SearchResponse.java @@ -19,8 +19,11 @@ public static class AllCategoryDto { @Schema(description = "7대자연 조회 결과") private ResultDto nature; - @Schema(description = "이색체험 조회 결과") - private ResultDto experience; + @Schema(description = "액티비티 조회 결과") + private ResultDto activity; + + @Schema(description = "문화예술 조회 결과") + private ResultDto cultureAndArts; @Schema(description = "전통시장 조회 결과") private ResultDto market; diff --git a/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java b/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java index 826fcf7e..2c6d3207 100644 --- a/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java +++ b/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java @@ -11,6 +11,7 @@ import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.dto.CompositeDto; import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto; +import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType; import com.jeju.nanaland.domain.experience.repository.ExperienceRepository; import com.jeju.nanaland.domain.favorite.service.MemberFavoriteService; import com.jeju.nanaland.domain.festival.dto.FestivalSearchDto; @@ -81,8 +82,11 @@ public SearchResponse.AllCategoryDto searchAll(MemberInfoDto memberInfoDto, Stri () -> searchFestival(memberInfoDto, keyword, page, size)); CompletableFuture marketFuture = CompletableFuture.supplyAsync( () -> searchMarket(memberInfoDto, keyword, page, size)); - CompletableFuture experienceFuture = CompletableFuture.supplyAsync( - () -> searchExperience(memberInfoDto, keyword, page, size)); + CompletableFuture activityFuture = CompletableFuture.supplyAsync( + () -> searchExperience(memberInfoDto, ExperienceType.ACTIVITY, keyword, page, size)); + CompletableFuture cultureAndArtsFuture = CompletableFuture.supplyAsync( + () -> searchExperience(memberInfoDto, ExperienceType.CULTURE_AND_ARTS, keyword, page, + size)); CompletableFuture restaurantFuture = CompletableFuture.supplyAsync( () -> searchRestaurant(memberInfoDto, keyword, page, size)); CompletableFuture nanaFuture = CompletableFuture.supplyAsync( @@ -93,7 +97,8 @@ public SearchResponse.AllCategoryDto searchAll(MemberInfoDto memberInfoDto, Stri natureFuture, festivalFuture, marketFuture, - experienceFuture, + activityFuture, + cultureAndArtsFuture, restaurantFuture, nanaFuture ).join(); @@ -103,7 +108,8 @@ public SearchResponse.AllCategoryDto searchAll(MemberInfoDto memberInfoDto, Stri .nature(natureFuture.join()) .festival(festivalFuture.join()) .market(marketFuture.join()) - .experience(experienceFuture.join()) + .activity(activityFuture.join()) + .cultureAndArts(cultureAndArtsFuture.join()) .restaurant(restaurantFuture.join()) .nana(nanaFuture.join()) .build(); @@ -124,14 +130,16 @@ public SearchResponse.ResultDto searchNature(MemberInfoDto memberInfoDto, String Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = natureRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = natureRepository.findSearchDtoByKeywordsUnion(combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 @@ -176,14 +184,16 @@ public SearchResponse.ResultDto searchFestival(MemberInfoDto memberInfoDto, Stri Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = festivalRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = festivalRepository.findSearchDtoByKeywordsUnion(combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 @@ -223,26 +233,28 @@ public SearchResponse.ResultDto searchFestival(MemberInfoDto memberInfoDto, Stri * @param size 페이지 크기 * @return 이색체험 검색 결과 */ - public SearchResponse.ResultDto searchExperience(MemberInfoDto memberInfoDto, String keyword, - int page, int size) { + public SearchResponse.ResultDto searchExperience(MemberInfoDto memberInfoDto, + ExperienceType experienceType, String keyword, int page, int size) { Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = experienceRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, - language, pageable); + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = experienceRepository.findSearchDtoByKeywordsUnion(experienceType, + combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 else { - resultPage = experienceRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, - language, pageable); + resultPage = experienceRepository.findSearchDtoByKeywordsIntersect(experienceType, + normalizedKeywords, language, pageable); } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); @@ -281,14 +293,16 @@ public SearchResponse.ResultDto searchMarket(MemberInfoDto memberInfoDto, String Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = marketRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = marketRepository.findSearchDtoByKeywordsUnion(combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 @@ -332,14 +346,16 @@ public SearchResponse.ResultDto searchRestaurant(MemberInfoDto memberInfoDto, St Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = restaurantRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = restaurantRepository.findSearchDtoByKeywordsUnion(combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 @@ -385,14 +401,16 @@ public SearchResponse.ResultDto searchNana(MemberInfoDto memberInfoDto, String k Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 - .map(String::toLowerCase) // 소문자로 - .toList(); + + // 사용자 검색어 정규화 + List normalizedKeywords = normalizeKeyword(keyword); Page resultPage; // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 if (normalizedKeywords.size() <= 4) { - resultPage = nanaRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + // 검색어 조합 + List combinedKeywords = combineUserKeywords(normalizedKeywords); + resultPage = nanaRepository.findSearchDtoByKeywordsUnion(combinedKeywords, language, pageable); } // 4개보다 많다면 Intersect 검색 @@ -578,4 +596,46 @@ private List getTopSearchVolumeList() { Set topSearchVolumes = zSetOperations.reverseRange(SEARCH_VOLUME_KEY, 0, 3); return topSearchVolumes != null ? new ArrayList<>(topSearchVolumes) : new ArrayList<>(); } + + /** + * 사용자 검색어 정규화 검색어를 공백으로 구분하고 '-', '_' 제거, 모든 문자를 소문자로 변환 + * + * @param keyword 사용자 검색어 + * @return 공백으로 구분되고 정규화한 검색어 리스트 + */ + List normalizeKeyword(String keyword) { + return Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(splittedKeyword -> splittedKeyword + .replace("-", "") // 하이픈 제거 + .replace("_", "") // 언더스코어 제거 + .toLowerCase() // 소문자로 + ) + .toList(); + } + + + /** + * 검색으로 들어온 키워드 조합 예를 들어 [jeju city restaurant]가 인자로 들어오면 [jeju, city, restaurant, jejucity, + * jejucityrestaurant, cityrestaurant]를 반환 + * + * @param keywords 사용자의 검색어 리스트 + * @return 조합된 사용자의 검색어 + */ + private List combineUserKeywords(List keywords) { + if (keywords.size() == 1) { + return keywords; + } + + List combinedKeywords = new ArrayList<>(keywords); + for (int i = 0; i < keywords.size() - 1; i++) { + StringBuilder combinedKeyword = new StringBuilder(); + combinedKeyword.append(keywords.get(i)); + for (int j = i + 1; j < keywords.size(); j++) { + combinedKeyword.append(keywords.get(j)); + combinedKeywords.add(combinedKeyword.toString()); + } + } + + return combinedKeywords; + } } diff --git a/src/main/java/com/jeju/nanaland/global/exception/ErrorCode.java b/src/main/java/com/jeju/nanaland/global/exception/ErrorCode.java index 247b40d8..6b74c3a0 100644 --- a/src/main/java/com/jeju/nanaland/global/exception/ErrorCode.java +++ b/src/main/java/com/jeju/nanaland/global/exception/ErrorCode.java @@ -19,8 +19,7 @@ public enum ErrorCode { BAD_REQUEST_EXCEPTION(BAD_REQUEST, "잘못된 요청입니다."), REQUEST_VALIDATION_EXCEPTION(BAD_REQUEST, "입력 형태가 잘못된 요청입니다."), MEMBER_CONSENT_BAD_REQUEST(BAD_REQUEST, "TERMS_OF_USE는 필수로 동의해야 합니다."), - IMAGE_BAD_REQUEST(BAD_REQUEST, "이미지는 최대 5장까지 가능합니다."), - REVIEW_IMAGE_BAD_REQUEST(BAD_REQUEST, "리뷰 이미지는 최대 5장까지 가능합니다."), + FILE_LIMIT_BAD_REQUEST(BAD_REQUEST, "파일은 최대 5개까지 가능합니다"), START_DATE_AFTER_END_DATE(BAD_REQUEST, "endDate가 startDate보다 앞서 있습니다."), INVALID_EXPERIENCE_TYPE(BAD_REQUEST, "이색체험 타입은 ACTIVITY, CULTURE_AND_ARTS 만 가능합니다."), INVALID_RESTAURANT_KEYWORD_TYPE(BAD_REQUEST, "잘못된 맛집 키워드입니다."), @@ -32,7 +31,6 @@ public enum ErrorCode { SELF_REPORT_NOT_ALLOWED(BAD_REQUEST, "본인을 신고하는 요청은 유효하지 않습니다."), ALREADY_REPORTED(BAD_REQUEST, "이미 신고되었습니다."), NO_NOTIFICATION_CONSENT(BAD_REQUEST, "알림 동의를 하지 않은 유저입니다."), - INVALID_FILE_SIZE(BAD_REQUEST, "파일 크기가 유효하지 않습니다."), INVALID_FILE_EXTENSION_TYPE(BAD_REQUEST, "해당 카테고리에서 지원하지 않는 파일 형식입니다."), NO_FILE_EXTENSION(BAD_REQUEST, "파일 확장자가 없습니다."), diff --git a/src/main/java/com/jeju/nanaland/global/file/data/FileCategory.java b/src/main/java/com/jeju/nanaland/global/file/data/FileCategory.java index 59e5c5e9..b9e16db4 100644 --- a/src/main/java/com/jeju/nanaland/global/file/data/FileCategory.java +++ b/src/main/java/com/jeju/nanaland/global/file/data/FileCategory.java @@ -17,4 +17,20 @@ public enum FileCategory { FileCategory(List allowedExtensions) { this.allowedExtensions = allowedExtensions; } + + public boolean isImage(String fileKey) { + String extension = getExtension(fileKey); + return allowedExtensions.contains(extension) && + Arrays.asList("jpeg", "jpg", "png", "webp").contains(extension); + } + + public boolean isVideo(String filename) { + String extension = getExtension(filename); + return allowedExtensions.contains(extension) && + Arrays.asList("mp4", "mov", "webm").contains(extension); + } + + private String getExtension(String fileKey) { + return fileKey.substring(fileKey.lastIndexOf(".") + 1).toLowerCase(); + } } diff --git a/src/main/java/com/jeju/nanaland/global/file/data/ImageSize.java b/src/main/java/com/jeju/nanaland/global/file/data/ImageSize.java new file mode 100644 index 00000000..281158d1 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/global/file/data/ImageSize.java @@ -0,0 +1,29 @@ +package com.jeju.nanaland.global.file.data; + +import java.util.Arrays; + +public enum ImageSize { + MEMBER_PROFILE("member_profile", 50, 50), + REVIEW("review", 70, 70), + TEST("test", 100, 100); + + private final String directory; + private final int width; + private final int height; + + ImageSize(String directory, int width, int height) { + this.directory = directory; + this.width = width; + this.height = height; + } + + public static String getDimension(String fileKey) { + String directory = fileKey.substring(0, fileKey.lastIndexOf('/')); + + return Arrays.stream(values()) + .filter(size -> size.directory.equals(directory.replaceFirst("^/", ""))) + .findFirst() + .map(size -> String.format("?w=%d&h=%d", size.width, size.height)) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/jeju/nanaland/global/file/dto/FileRequest.java b/src/main/java/com/jeju/nanaland/global/file/dto/FileRequest.java index 45c5f765..125dd6e8 100644 --- a/src/main/java/com/jeju/nanaland/global/file/dto/FileRequest.java +++ b/src/main/java/com/jeju/nanaland/global/file/dto/FileRequest.java @@ -3,6 +3,8 @@ import com.jeju.nanaland.domain.common.annotation.EnumValid; import com.jeju.nanaland.global.file.data.FileCategory; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -23,10 +25,12 @@ public static class InitCommandDto { @NotNull @Schema(description = "파일 크기") + @Max(30 * 1024 * 1024) private Long fileSize; @NotNull @Schema(description = "파일 파트 개수") + @Min(1) private int partCount; @EnumValid( diff --git a/src/main/java/com/jeju/nanaland/global/file/service/FileUploadService.java b/src/main/java/com/jeju/nanaland/global/file/service/FileUploadService.java index b012bcde..206ca515 100644 --- a/src/main/java/com/jeju/nanaland/global/file/service/FileUploadService.java +++ b/src/main/java/com/jeju/nanaland/global/file/service/FileUploadService.java @@ -1,9 +1,9 @@ package com.jeju.nanaland.global.file.service; +import static com.jeju.nanaland.global.exception.ErrorCode.FILE_LIMIT_BAD_REQUEST; import static com.jeju.nanaland.global.exception.ErrorCode.FILE_S3_NOT_FOUNE; import static com.jeju.nanaland.global.exception.ErrorCode.FILE_UPLOAD_FAIL; import static com.jeju.nanaland.global.exception.ErrorCode.INVALID_FILE_EXTENSION_TYPE; -import static com.jeju.nanaland.global.exception.ErrorCode.INVALID_FILE_SIZE; import static com.jeju.nanaland.global.exception.ErrorCode.NO_FILE_EXTENSION; import com.amazonaws.HttpMethod; @@ -13,20 +13,21 @@ import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.InitiateMultipartUploadRequest; import com.amazonaws.services.s3.model.InitiateMultipartUploadResult; -import com.amazonaws.services.s3.model.PartETag; import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PartETag; import com.jeju.nanaland.global.exception.BadRequestException; import com.jeju.nanaland.global.exception.NotFoundException; import com.jeju.nanaland.global.exception.ServerErrorException; import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.data.ImageSize; import com.jeju.nanaland.global.file.dto.FileRequest; import com.jeju.nanaland.global.file.dto.FileResponse; import com.jeju.nanaland.global.file.dto.FileResponse.InitResultDto; import com.jeju.nanaland.global.file.dto.FileResponse.PresignedUrlInfo; import com.jeju.nanaland.global.image_upload.dto.S3ImageDto; +import com.jeju.nanaland.global.image_upload.dto.S3VideoDto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import java.net.URL; import java.time.LocalDate; import java.time.LocalDateTime; @@ -46,6 +47,8 @@ @Slf4j public class FileUploadService { + private static final int MAX_IMAGE_COUNT = 5; + private static final int PRESIGNEDURL_EXPIRATION = 30; private final AmazonS3 amazonS3; private final AmazonS3Client amazonS3Client; @Value("${cloud.aws.cloudfront.domain}") @@ -60,18 +63,11 @@ public class FileUploadService { private String infoFixReportDirectory; @Value("${cloud.aws.s3.claimReportFileDirectory}") private String claimReportDirectory; - private static final String THUMBNAIL_DIRECTORY = "/thumbnail_images"; - private static final String THUMBNAIL_PREFIX = "thumbnail_"; - private static final int PRESIGNEDURL_EXPIRATION = 30; - private static final long MAX_FILE_SIZE = 30 * 1024 * 1024L; public FileResponse.InitResultDto uploadInit(FileRequest.InitCommandDto initCommandDto) { - // 파일 크기 유효성 검사 - validateFileSize(initCommandDto.getFileSize()); - // 파일 형식 유효성 검사 String contentType = validateFileExtension(initCommandDto.getOriginalFileName(), - initCommandDto.getFileCategory()); + FileCategory.valueOf(initCommandDto.getFileCategory())); // S3 key 생성 String fileKey = generateUniqueFileKey(initCommandDto.getOriginalFileName(), @@ -90,41 +86,44 @@ public FileResponse.InitResultDto uploadInit(FileRequest.InitCommandDto initComm InitiateMultipartUploadResult initResponse = amazonS3.initiateMultipartUpload(initRequest); String uploadId = initResponse.getUploadId(); - List presignedUrlInfos = new ArrayList<>(); - // 파트별 Pre-Signed URL 발급 - for (int partNumber = 1; partNumber <= initCommandDto.getPartCount(); partNumber++) { - GeneratePresignedUrlRequest presignedUrlRequest = new GeneratePresignedUrlRequest(bucket, fileKey) - .withMethod(HttpMethod.PUT) - .withExpiration(getPresignedUrlExpiration()) - .withKey(fileKey); - - presignedUrlRequest.addRequestParameter("partNumber", String.valueOf(partNumber)); - presignedUrlRequest.addRequestParameter("uploadId", uploadId); - URL presignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest); - - presignedUrlInfos.add(PresignedUrlInfo.builder() - .partNumber(partNumber) - .preSignedUrl(presignedUrl.toString()) - .build()); - } - return InitResultDto.builder() - .uploadId(uploadId) - .fileKey(fileKey) - .presignedUrlInfos(presignedUrlInfos) - .build(); + List presignedUrlInfos = new ArrayList<>(); + // 파트별 Pre-Signed URL 발급 + for (int partNumber = 1; partNumber <= initCommandDto.getPartCount(); partNumber++) { + GeneratePresignedUrlRequest presignedUrlRequest = new GeneratePresignedUrlRequest(bucket, + fileKey) + .withMethod(HttpMethod.PUT) + .withExpiration(getPresignedUrlExpiration()) + .withKey(fileKey); + + presignedUrlRequest.addRequestParameter("partNumber", String.valueOf(partNumber)); + presignedUrlRequest.addRequestParameter("uploadId", uploadId); + URL presignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest); + + presignedUrlInfos.add(PresignedUrlInfo.builder() + .partNumber(partNumber) + .preSignedUrl(presignedUrl.toString()) + .build()); + } + return InitResultDto.builder() + .uploadId(uploadId) + .fileKey(fileKey) + .presignedUrlInfos(presignedUrlInfos) + .build(); } catch (Exception e) { log.error("Pre-Signed URL Init 실패 : {}", e.getMessage()); throw new ServerErrorException(FILE_UPLOAD_FAIL.getMessage()); } } - private void validateFileSize(@NotNull Long fileSize) { - if (fileSize > MAX_FILE_SIZE) { - throw new BadRequestException(INVALID_FILE_SIZE.getMessage()); - } - } - - private String validateFileExtension(@NotBlank String originalFileName, String fileCategory) { + /** + * 파일 확장자 유효성 확인 + * + * @param originalFileName 파일명 + * @param fileCategory 파일 카테고리 + * @return 파일 content Type + */ + public String validateFileExtension(@NotBlank String originalFileName, + FileCategory fileCategory) { if (originalFileName == null || !originalFileName.contains(".")) { throw new BadRequestException(NO_FILE_EXTENSION.getMessage()); } @@ -133,7 +132,7 @@ private String validateFileExtension(@NotBlank String originalFileName, String f .substring(originalFileName.lastIndexOf('.') + 1) .toLowerCase(); - if (!FileCategory.valueOf(fileCategory).getAllowedExtensions().contains(extension)) { + if (!fileCategory.getAllowedExtensions().contains(extension)) { throw new BadRequestException(INVALID_FILE_EXTENSION_TYPE.getMessage()); } @@ -201,18 +200,45 @@ public S3ImageDto getCloudImageUrls(String fileKey) { throw new NotFoundException(FILE_S3_NOT_FOUNE.getMessage()); } String originUrl = cloudFrontDomain + "/" + fileKey; + String thumbnailUrl = cloudFrontDomain + "/" + fileKey; + String dimension = ImageSize.getDimension(fileKey); - if (!amazonS3Client.doesObjectExist(bucket + THUMBNAIL_DIRECTORY, THUMBNAIL_PREFIX + fileKey)) { - return S3ImageDto.builder() - .originUrl(originUrl) - .thumbnailUrl(originUrl) - .build(); + if (dimension != null) { + thumbnailUrl += dimension; } - String thumbnailUrl = cloudFrontDomain + "/" + THUMBNAIL_DIRECTORY + "/" + THUMBNAIL_PREFIX + fileKey; return S3ImageDto.builder() .originUrl(originUrl) .thumbnailUrl(thumbnailUrl) .build(); } + + public S3VideoDto getCloudVideoUrls(String fileKey) { + if (!amazonS3Client.doesObjectExist(bucket, fileKey)) { + throw new NotFoundException(FILE_S3_NOT_FOUNE.getMessage()); + } + String originUrl = cloudFrontDomain + "/" + fileKey; + + return S3VideoDto.builder() + .originUrl(originUrl) + .build(); + } + + /** + * 파일 개수 유효성 확인 + * + * @param fileKeys 파일키 리스트 + * @throws BadRequestException 파일 개수가 초과된 경우 + */ + public void validateFileKeys(List fileKeys, FileCategory fileCategory) { + if (fileKeys == null) { + return; + } + + if (fileKeys.size() > MAX_IMAGE_COUNT) { + throw new BadRequestException(FILE_LIMIT_BAD_REQUEST.getMessage()); + } + + fileKeys.forEach(fileKey -> validateFileExtension(fileKey, fileCategory)); + } } diff --git a/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java index 84a26e31..dd2e26ba 100644 --- a/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java +++ b/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java @@ -137,7 +137,8 @@ void getExperienceTypeKeywordSetTest() { @ParameterizedTest @EnumSource(value = Language.class) - void findSearchDtoByKeywordsUnionTest(Language language) { + @DisplayName("액티비티 Union 검색") + void findSearchDtoByKeywordsUnionActivityTest(Language language) { // given Pageable pageable = PageRequest.of(0, 10); List experiences1 = @@ -149,17 +150,39 @@ void findSearchDtoByKeywordsUnionTest(Language language) { // when Page resultDto = experienceRepository.findSearchDtoByKeywordsUnion( - List.of("keyword2", "keyword3"), language, pageable); + ExperienceType.ACTIVITY, List.of("keyword2", "keyword3"), language, pageable); // then - assertThat(resultDto.getTotalElements()).isEqualTo(5); + assertThat(resultDto.getTotalElements()).isEqualTo(2); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(1); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("문화예술 Union 검색") + void findSearchDtoByKeywordsUnionCultureAndArtsTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 10); + List experiences1 = + getActivityList(language, List.of(LAND_LEISURE, WATER_LEISURE), "제주시", 2); + initHashtags(experiences1, List.of("keyword1", "kEyWoRd2"), language); + List experiences2 = + getCultureAndArtsList(language, List.of(EXHIBITION, MUSEUM, ART_MUSEUM), "서귀포시", 3); + initHashtags(experiences2, List.of("keyword2", "kEyWoRd3"), language); + + // when + Page resultDto = experienceRepository.findSearchDtoByKeywordsUnion( + ExperienceType.CULTURE_AND_ARTS, List.of("keyword2", "keyword3"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); - assertThat(resultDto.getContent().get(3).getMatchedCount()).isEqualTo(1); } @ParameterizedTest @EnumSource(value = Language.class) - void findSearchDtoByKeywordsIntersectTest(Language language) { + @DisplayName("액티비티 Union 검색") + void findSearchDtoByKeywordsIntersectActivityTest(Language language) { // given Pageable pageable = PageRequest.of(0, 10); List keywords = List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"); @@ -168,16 +191,38 @@ void findSearchDtoByKeywordsIntersectTest(Language language) { initHashtags(experiences1, keywords, language); List experiences2 = getCultureAndArtsList(language, List.of(EXHIBITION, MUSEUM, ART_MUSEUM), "서귀포시", 3); - initHashtags(experiences2, List.of("keyword1", "kEyWoRd2"), language); + initHashtags(experiences2, keywords, language); // when Page resultDto = experienceRepository.findSearchDtoByKeywordsIntersect( - keywords, language, pageable); + ExperienceType.ACTIVITY, keywords, language, pageable); // then assertThat(resultDto.getTotalElements()).isEqualTo(2); } + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("문화예술 Union 검색") + void findSearchDtoByKeywordsIntersectCultureAndArtsTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 10); + List keywords = List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"); + List experiences1 = + getActivityList(language, List.of(LAND_LEISURE, WATER_LEISURE), "제주시", 2); + initHashtags(experiences1, keywords, language); + List experiences2 = + getCultureAndArtsList(language, List.of(EXHIBITION, MUSEUM, ART_MUSEUM), "서귀포시", 3); + initHashtags(experiences2, keywords, language); + + // when + Page resultDto = experienceRepository.findSearchDtoByKeywordsIntersect( + ExperienceType.CULTURE_AND_ARTS, keywords, language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); + } + private List getActivityList(Language language, List keywordList, String addressTag, int size) { List experienceList = new ArrayList<>(); diff --git a/src/test/java/com/jeju/nanaland/domain/member/service/MemberLoginServiceTest.java b/src/test/java/com/jeju/nanaland/domain/member/service/MemberLoginServiceTest.java index 16ead2ff..2656ea3c 100644 --- a/src/test/java/com/jeju/nanaland/domain/member/service/MemberLoginServiceTest.java +++ b/src/test/java/com/jeju/nanaland/domain/member/service/MemberLoginServiceTest.java @@ -14,6 +14,7 @@ import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.data.Status; import com.jeju.nanaland.domain.common.entity.ImageFile; +import com.jeju.nanaland.domain.common.service.ImageFileService; import com.jeju.nanaland.domain.member.dto.MemberRequest; import com.jeju.nanaland.domain.member.dto.MemberRequest.JoinDto; import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; @@ -32,6 +33,7 @@ import com.jeju.nanaland.global.exception.ErrorCode; import com.jeju.nanaland.global.exception.NotFoundException; import com.jeju.nanaland.global.exception.UnauthorizedException; +import com.jeju.nanaland.global.file.service.FileUploadService; import com.jeju.nanaland.global.util.JwtUtil; import java.time.LocalDate; import java.util.ArrayList; @@ -47,8 +49,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; @ExtendWith(MockitoExtension.class) @@ -71,6 +71,10 @@ class MemberLoginServiceTest { private FcmTokenService fcmTokenService; @Mock private MemberProfileService memberProfileService; + @Mock + private FileUploadService fileUploadService; + @Mock + private ImageFileService imageFileService; @InjectMocks private MemberLoginService memberLoginService; @@ -243,26 +247,22 @@ void joinSuccess_multipartFileExists() { // given: 프로필 사진이 있는 경우 Language language = Language.KOREAN; Member member = createMember(language, joinDto); - MultipartFile multipartFile = new MockMultipartFile("file", "test.jpg", "image/jpeg", - new byte[0]); + String fileKey = "test/1.png"; doReturn(Optional.empty()) .when(memberRepository).findByProviderAndProviderId(any(Provider.class), any(String.class)); doReturn(Optional.empty()).when(memberRepository).findByNickname(any(String.class)); - doReturn(imageFile).when(memberProfileService).saveRandomProfileImageFile(); doReturn(member).when(memberRepository).save(any(Member.class)); doReturn("accessToken").when(jwtUtil).createAccessToken(any(String.class), anySet()); doReturn("refreshToken").when(jwtUtil).createRefreshToken(any(String.class), anySet()); // when: 회원 가입 - JwtDto result = memberLoginService.join(joinDto, multipartFile); + JwtDto result = memberLoginService.join(joinDto, fileKey); // then: JWT 생성 확인, 이용약관 생성 확인, 프로필 사진 확인 assertThat(result).isNotNull(); assertThat(result.getAccessToken()).isEqualTo("accessToken"); assertThat(result.getRefreshToken()).isEqualTo("refreshToken"); verify(memberConsentService).createMemberConsents(any(Member.class), anyList()); - verify(memberRepository).save(argThat(savedMember -> - savedMember.getProfileImageFile().equals(imageFile))); } @Test @@ -273,27 +273,22 @@ void joinSuccess_fcmTokenExists(){ JoinDto joinDto2 = createJoinDto("GOOGLE"); joinDto2.setFcmToken("fcmToken"); Member member = createMember(language, joinDto2); - MultipartFile multipartFile = new MockMultipartFile("file", "test.jpg", "image/jpeg", - new byte[0]); doReturn(Optional.empty()) .when(memberRepository).findByProviderAndProviderId(any(Provider.class), any(String.class)); doReturn(Optional.empty()).when(memberRepository).findByNickname(any(String.class)); - doReturn(imageFile).when(memberProfileService).saveRandomProfileImageFile(); doReturn(member).when(memberRepository).save(any(Member.class)); doReturn("accessToken").when(jwtUtil).createAccessToken(any(String.class), anySet()); doReturn("refreshToken").when(jwtUtil).createRefreshToken(any(String.class), anySet()); // when: 회원 가입 - JwtDto result = memberLoginService.join(joinDto2, multipartFile); + JwtDto result = memberLoginService.join(joinDto2, null); - // then: JWT 생성 확인, 이용약관 생성 확인, fcmToken 생성 확인, 프로필 사진 확인 + // then: JWT 생성 확인, 이용약관 생성 확인, fcmToken 생성 확인 assertThat(result).isNotNull(); assertThat(result.getAccessToken()).isEqualTo("accessToken"); assertThat(result.getRefreshToken()).isEqualTo("refreshToken"); verify(memberConsentService).createMemberConsents(any(Member.class), anyList()); verify(fcmTokenService).createFcmToken(any(Member.class), any(String.class)); - verify(memberRepository).save(argThat(savedMember -> - savedMember.getProfileImageFile().equals(imageFile))); } } diff --git a/src/test/java/com/jeju/nanaland/domain/report/service/ReportServiceTest.java b/src/test/java/com/jeju/nanaland/domain/report/service/ReportServiceTest.java index a1da31d7..94f8ef2d 100644 --- a/src/test/java/com/jeju/nanaland/domain/report/service/ReportServiceTest.java +++ b/src/test/java/com/jeju/nanaland/domain/report/service/ReportServiceTest.java @@ -1,7 +1,7 @@ package com.jeju.nanaland.domain.report.service; import static com.jeju.nanaland.global.exception.ErrorCode.ALREADY_REPORTED; -import static com.jeju.nanaland.global.exception.ErrorCode.IMAGE_BAD_REQUEST; +import static com.jeju.nanaland.global.exception.ErrorCode.FILE_LIMIT_BAD_REQUEST; import static com.jeju.nanaland.global.exception.ErrorCode.MEMBER_NOT_FOUND; import static com.jeju.nanaland.global.exception.ErrorCode.NANA_INFO_FIX_FORBIDDEN; import static com.jeju.nanaland.global.exception.ErrorCode.NOT_FOUND_EXCEPTION; @@ -10,11 +10,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; +import com.jeju.nanaland.domain.common.entity.ImageFile; +import com.jeju.nanaland.domain.common.entity.VideoFile; import com.jeju.nanaland.domain.common.service.FileService; import com.jeju.nanaland.domain.common.service.ImageFileService; import com.jeju.nanaland.domain.common.service.MailService; @@ -26,6 +30,7 @@ import com.jeju.nanaland.domain.member.entity.enums.TravelType; import com.jeju.nanaland.domain.member.repository.MemberRepository; import com.jeju.nanaland.domain.report.dto.ReportRequest; +import com.jeju.nanaland.domain.report.entity.ReportType; import com.jeju.nanaland.domain.report.entity.claim.ClaimReportStrategy; import com.jeju.nanaland.domain.report.entity.infoFix.FixType; import com.jeju.nanaland.domain.report.entity.infoFix.InfoFixReport; @@ -41,7 +46,8 @@ import com.jeju.nanaland.domain.review.repository.ReviewRepository; import com.jeju.nanaland.global.exception.BadRequestException; import com.jeju.nanaland.global.exception.NotFoundException; -import java.nio.charset.StandardCharsets; +import com.jeju.nanaland.global.file.data.FileCategory; +import com.jeju.nanaland.global.file.service.FileUploadService; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -58,8 +64,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; @ExtendWith(MockitoExtension.class) @Execution(ExecutionMode.CONCURRENT) @@ -94,25 +98,25 @@ class ReportServiceTest { ClaimReportStrategy claimReportStrategy; @Mock FileService fileService; + @Mock + FileUploadService fileUploadService; MemberInfoDto memberInfoDto, memberInfoDto2; - private static List createImageMultipartFiles(int itemCount) { - List files = new ArrayList<>(); + private static List createImageFileKeys(int itemCount) { + List fileKeys = new ArrayList<>(); for (int i = 0; i < itemCount; i++) { - files.add(new MockMultipartFile("image", "test.png", "image/png", - "test file".getBytes(StandardCharsets.UTF_8))); + fileKeys.add("test/" + i + ".jpg"); } - return files; + return fileKeys; } - private static List createVideoMultipartFiles(int itemCount) { - List files = new ArrayList<>(); + private static List createVideoFileKeys(int itemCount) { + List fileKeys = new ArrayList<>(); for (int i = 0; i < itemCount; i++) { - files.add(new MockMultipartFile("video", "test.mp4", "video/mp4", - "test file".getBytes(StandardCharsets.UTF_8))); + fileKeys.add("test/" + itemCount + ".mp4"); } - return files; + return fileKeys; } @BeforeEach @@ -173,7 +177,7 @@ void requestPostInfoFixFail_invalidCategory(Category category) { // when: 정보 수정 제안 // then: ErrorCode 검증 - assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto, null)) + assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto)) .isInstanceOf(BadRequestException.class) .hasMessage(NANA_INFO_FIX_FORBIDDEN.getMessage()); } @@ -183,13 +187,17 @@ void requestPostInfoFixFail_invalidCategory(Category category) { void requestPostInfoFixFail_fileCountOverLimit() { // given: 파일 개수가 초과되도록 설정 ReportRequest.InfoFixDto infoFixDto = createInfoFixDto(Category.MARKET); - List files = createImageMultipartFiles(6); + List fileKeys = createImageFileKeys(6); + infoFixDto.setFileKeys(fileKeys); + doThrow(new BadRequestException(FILE_LIMIT_BAD_REQUEST.getMessage())) + .when(fileUploadService) + .validateFileKeys(any(), any(FileCategory.class)); // when: 정보 수정 제안 // then: ErrorCode 검증 - assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto, files)) + assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto)) .isInstanceOf(BadRequestException.class) - .hasMessage(IMAGE_BAD_REQUEST.getMessage()); + .hasMessage(FILE_LIMIT_BAD_REQUEST.getMessage()); } @Test @@ -202,7 +210,7 @@ void requestPostInfoFixFail_postNotFound() { // when: 정보 수정 제안 // then: ErrorCode 검증 - assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto, null)) + assertThatThrownBy(() -> reportService.requestPostInfoFix(memberInfoDto, infoFixDto)) .isInstanceOf(NotFoundException.class) .hasMessage(NOT_FOUND_EXCEPTION.getMessage()); } @@ -213,14 +221,18 @@ void requestPostInfoFixSuccess() { // given: 정보 수정 제안 요청 설정 ReportRequest.InfoFixDto infoFixDto = createInfoFixDto(Category.MARKET); int itemCount = 3; - List files = createImageMultipartFiles(itemCount); + List fileKeys = createImageFileKeys(itemCount); + infoFixDto.setFileKeys(fileKeys); doReturn(MarketCompositeDto.builder().build()).when(marketRepository) .findCompositeDtoById(any(), any(Language.class)); + doReturn(mock(ImageFile.class)).when(imageFileService) + .getAndSaveImageFile(any()); + doReturn(infoFixReportStrategy).when(reportStrategyFactory).findStrategy(any(ReportType.class)); doReturn(null).when(infoFixReportRepository).save(any(InfoFixReport.class)); // when: 정보 수정 제안 - reportService.requestPostInfoFix(memberInfoDto, infoFixDto, files); + reportService.requestPostInfoFix(memberInfoDto, infoFixDto); // then: 정보 수정 제안 요청 검증 verify(infoFixReportRepository).save(any(InfoFixReport.class)); @@ -241,7 +253,7 @@ void requestClaimReportFail_reviewNotFound() { // when: 리뷰 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, null)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(NotFoundException.class) .hasMessage(REVIEW_NOT_FOUND.getMessage()); } @@ -257,7 +269,7 @@ void requestClaimReportFail_selfReportedReview() { // when: 리뷰 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, null)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(BadRequestException.class) .hasMessage(SELF_REPORT_NOT_ALLOWED.getMessage()); } @@ -275,7 +287,7 @@ void requestClaimReportFail_alreadyReported() { // when: 리뷰 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, null)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(BadRequestException.class) .hasMessage(ALREADY_REPORTED.getMessage()); } @@ -286,7 +298,12 @@ void requestClaimReportFail_fileCountOverLimit() { // given: 파일 개수가 초과되도록 설정 ReportRequest.ClaimReportDto claimReportDto = createClaimReportDto(ClaimReportType.REVIEW); Review review = createReview(memberInfoDto2.getMember()); - List files = createImageMultipartFiles(6); + List fileKeys = createImageFileKeys(6); + claimReportDto.setFileKeys(fileKeys); + + doThrow(new BadRequestException(FILE_LIMIT_BAD_REQUEST.getMessage())) + .when(fileUploadService) + .validateFileKeys(any(), any(FileCategory.class)); doReturn(Optional.of(review)).when(reviewRepository).findById(any()); doReturn(Optional.empty()).when(claimReportRepository) .findByMemberAndReferenceIdAndClaimReportType(any(Member.class), any(), any(ClaimReportType.class)); @@ -294,9 +311,9 @@ void requestClaimReportFail_fileCountOverLimit() { // when: 리뷰 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, files)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(BadRequestException.class) - .hasMessage(IMAGE_BAD_REQUEST.getMessage()); + .hasMessage(FILE_LIMIT_BAD_REQUEST.getMessage()); } @Test @@ -307,17 +324,24 @@ void requestReviewClaimReportSuccess() { Review review = createReview(memberInfoDto2.getMember()); int imageCount = 3; int videoCount = 2; - List imageMultipartFiles = createImageMultipartFiles(imageCount); - List videoMultipartFiles = createVideoMultipartFiles(videoCount); - List files = new ArrayList<>(imageMultipartFiles); - files.addAll(videoMultipartFiles); + List imageFileKeys = createImageFileKeys(imageCount); + List videoFileKeys = createVideoFileKeys(videoCount); + List fileKeys = new ArrayList<>(imageFileKeys); + fileKeys.addAll(videoFileKeys); + claimReportDto.setFileKeys(fileKeys); + + doReturn(claimReportStrategy).when(reportStrategyFactory).findStrategy(any(ReportType.class)); doReturn(Optional.of(review)).when(reviewRepository).findById(any()); + doReturn(mock(ImageFile.class)).when(imageFileService) + .getAndSaveImageFile(any()); + doReturn(mock(VideoFile.class)).when(videoFileService) + .getAndSaveVideoFile(any()); doReturn(Optional.empty()).when(claimReportRepository) .findByMemberAndReferenceIdAndClaimReportType(any(Member.class), any(), any(ClaimReportType.class)); // when: 리뷰 신고 요청 - reportService.requestClaimReport(memberInfoDto, claimReportDto, files); + reportService.requestClaimReport(memberInfoDto, claimReportDto); // then: 리뷰 신고 요청 검증 verify(claimReportRepository).save(any(ClaimReport.class)); @@ -333,7 +357,7 @@ void requestClaimReportFail_selfReportedMember() { // when: 유저 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, null)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(BadRequestException.class) .hasMessage(SELF_REPORT_NOT_ALLOWED.getMessage()); } @@ -349,7 +373,7 @@ void requestClaimReportFail_memberNotFound() { // when: 유저 신고 요청 // then: ErrorCode 검증 assertThatThrownBy( - () -> reportService.requestClaimReport(memberInfoDto, claimReportDto, null)) + () -> reportService.requestClaimReport(memberInfoDto, claimReportDto)) .isInstanceOf(NotFoundException.class) .hasMessage(MEMBER_NOT_FOUND.getMessage()); } @@ -361,18 +385,25 @@ void requestMemberClaimReportSuccess() { ReportRequest.ClaimReportDto claimReportDto = createClaimReportDto(ClaimReportType.MEMBER); int imageCount = 3; int videoCount = 2; - List imageMultipartFiles = createImageMultipartFiles(imageCount); - List videoMultipartFiles = createVideoMultipartFiles(videoCount); - List files = new ArrayList<>(imageMultipartFiles); - files.addAll(videoMultipartFiles); + List imageFileKeys = createImageFileKeys(imageCount); + List videoFileKeys = createVideoFileKeys(videoCount); + + List fileKeys = new ArrayList<>(imageFileKeys); + fileKeys.addAll(videoFileKeys); + claimReportDto.setFileKeys(fileKeys); doReturn(2L).when(memberInfoDto.getMember()).getId(); doReturn(Optional.of(memberInfoDto2.getMember())).when(memberRepository).findById(any()); + doReturn(claimReportStrategy).when(reportStrategyFactory).findStrategy(any(ReportType.class)); + doReturn(mock(ImageFile.class)).when(imageFileService) + .getAndSaveImageFile(any()); + doReturn(mock(VideoFile.class)).when(videoFileService) + .getAndSaveVideoFile(any()); doReturn(Optional.empty()).when(claimReportRepository) .findByMemberAndReferenceIdAndClaimReportType(any(Member.class), any(), any(ClaimReportType.class)); // when: 유저 신고 요청 - reportService.requestClaimReport(memberInfoDto, claimReportDto, files); + reportService.requestClaimReport(memberInfoDto, claimReportDto); // then: 유저 신고 요청 검증 verify(claimReportRepository).save(any(ClaimReport.class)); diff --git a/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java b/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java index 14c9a248..e15d5445 100644 --- a/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java +++ b/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java @@ -1,6 +1,6 @@ package com.jeju.nanaland.domain.search.service; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; import com.jeju.nanaland.domain.experience.repository.ExperienceRepository; import com.jeju.nanaland.domain.favorite.service.MemberFavoriteService; @@ -10,7 +10,11 @@ import com.jeju.nanaland.domain.nature.repository.NatureRepository; import com.jeju.nanaland.domain.restaurant.repository.RestaurantRepository; import com.jeju.nanaland.global.config.RedisConfig; -import org.junit.jupiter.api.BeforeEach; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; @@ -48,10 +52,55 @@ class SearchServiceTest { @Mock private ZSetOperations zSetOperations; // ZSetOperations mock - @BeforeEach - public void setup() { - // opsForZSet() 호출 시 ZSetOperations mock을 반환하도록 설정 - when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + @Test + @DisplayName("검색어 정규화 테스트") + void normalizeKeywordTest() { + // given + String keyword = "JEJU Jeju-city Korean_Restaurant"; + + // when + List normalizedKeyword = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(splittedKeyword -> splittedKeyword + .replace("-", "") // 하이픈 제거 + .replace("_", "") // 언더스코어 제거 + .toLowerCase() // 소문자로 + ) + .toList(); + + // then + assertThat(normalizedKeyword).containsExactly( + "jeju", + "jejucity", + "koreanrestaurant" + ); + } + + @Test + @DisplayName("검색어 조합 테스트") + void combinationUserKeywordsTest() { + // given + List keywords = List.of("jeju", "city", "restaurant"); + + // when + List combinedKeywords = new ArrayList<>(keywords); + for (int i = 0; i < keywords.size() - 1; i++) { + StringBuilder combinedKeyword = new StringBuilder(); + combinedKeyword.append(keywords.get(i)); + for (int j = i + 1; j < keywords.size(); j++) { + combinedKeyword.append(keywords.get(j)); + combinedKeywords.add(combinedKeyword.toString()); + } + } + + // then + assertThat(combinedKeywords).containsExactly( + "jeju", + "city", + "restaurant", + "jejucity", + "jejucityrestaurant", + "cityrestaurant" + ); } } \ No newline at end of file