diff --git a/.gitignore b/.gitignore index 5e7af8e5..7dfc0eff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ bin/ ### IntelliJ IDEA ### .env -.oci-keys/ .idea *.iws *.iml diff --git a/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilChangeProfileImageRequest.java b/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilChangeProfileImageRequest.java deleted file mode 100644 index 1ee10712..00000000 --- a/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilChangeProfileImageRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.campus.campus.domain.council.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record StudentCouncilChangeProfileImageRequest( - @Schema(description = "학생회 프로필 이미지 url", example = "https://www.example.com.png") - String councilProfileImageUrl -) { -} diff --git a/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilNicknameRequest.java b/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilNicknameRequest.java deleted file mode 100644 index e49f7a3c..00000000 --- a/src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilNicknameRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.campus.campus.domain.council.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -public record StudentCouncilNicknameRequest( - @Schema(description = "학생회 닉네임", example = "CUBE") - @NotBlank - String councilNickname -) { -} diff --git a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilChangeProfileImageResponse.java b/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilChangeProfileImageResponse.java deleted file mode 100644 index 105a9158..00000000 --- a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilChangeProfileImageResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.campus.campus.domain.council.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record StudentCouncilChangeProfileImageResponse( - @Schema(description = "학생회 ID", example = "1") - Long councilId, - - @Schema(description = "학생회 이름", example = "가천대학교 총학생회") - String councilName, - - @Schema(description = "학생회 프로필 이미지 url", example = "https://www.example.com.png") - String councilProfileImageUrl -) { -} diff --git a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilLoginResponse.java b/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilLoginResponse.java index 74b3222f..8e2177a9 100644 --- a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilLoginResponse.java +++ b/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilLoginResponse.java @@ -35,15 +35,6 @@ public record StudentCouncilLoginResponse( String majorName, @Schema(description = "학생회 이름", example = "가천대학교 총학생회") - String councilName, - - @Schema(description = "학생회 닉네임", example = "CUBE") - String councilNickname, - - @Schema(description = "학생회 프로필 이미지 url", example = "https://www.example.com.png") - String councilProfileImageUrl, - - @Schema(description = "학생회 대표자 이름", example = "한승현") - String councilPresident + String councilName ) { } diff --git a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilNicknameResponse.java b/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilNicknameResponse.java deleted file mode 100644 index 875c234e..00000000 --- a/src/main/java/com/campus/campus/domain/council/application/dto/response/StudentCouncilNicknameResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.campus.campus.domain.council.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record StudentCouncilNicknameResponse( - @Schema(description = "학생회 닉네임", example = "CUBE") - String councilNickname, - - @Schema(description = "학생회 이름", example = "가천대학교 총학생회") - String councilName -) { -} diff --git a/src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilMapper.java b/src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilLoginMapper.java similarity index 71% rename from src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilMapper.java rename to src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilLoginMapper.java index 6bb181e3..d896c8cd 100644 --- a/src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilMapper.java +++ b/src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilLoginMapper.java @@ -3,10 +3,8 @@ import org.springframework.stereotype.Component; import com.campus.campus.domain.council.application.dto.request.StudentCouncilSignUpRequest; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilChangeProfileImageResponse; import com.campus.campus.domain.council.application.dto.response.StudentCouncilFindIdResponse; import com.campus.campus.domain.council.application.dto.response.StudentCouncilLoginResponse; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilNicknameResponse; import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.school.domain.entity.College; import com.campus.campus.domain.school.domain.entity.Major; @@ -17,7 +15,7 @@ @Component @RequiredArgsConstructor -public class StudentCouncilMapper { +public class StudentCouncilLoginMapper { private final SecurityConfig securityConfig; public StudentCouncil createStudentCouncil(StudentCouncilSignUpRequest studentCouncilSignUpRequest, School school, @@ -49,10 +47,7 @@ public StudentCouncilLoginResponse toStudentCouncilLoginResponse(StudentCouncil studentCouncil.getSchool().getSchoolName(), studentCouncil.getCollege() != null ? studentCouncil.getCollege().getCollegeName() : null, studentCouncil.getMajor() != null ? studentCouncil.getMajor().getMajorName() : null, - studentCouncil.getCouncilName(), - studentCouncil.getCouncilNickname(), - studentCouncil.getCouncilProfileImageUrl(), - studentCouncil.getCouncilPresident() + studentCouncil.getCouncilName() ); } @@ -61,20 +56,4 @@ public StudentCouncilFindIdResponse toStudentCouncilFindIdResponse(String loginI loginId ); } - - public StudentCouncilNicknameResponse toStudentCouncilNicknameResponse(StudentCouncil studentCouncil) { - return new StudentCouncilNicknameResponse( - studentCouncil.getCouncilNickname(), - studentCouncil.getCouncilName() - ); - } - - public StudentCouncilChangeProfileImageResponse toStudentCouncilChangeProfileImageResponse( - StudentCouncil studentCouncil) { - return new StudentCouncilChangeProfileImageResponse( - studentCouncil.getId(), - studentCouncil.getCouncilName(), - studentCouncil.getCouncilProfileImageUrl() - ); - } } diff --git a/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java b/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java index 6f4f8f21..484a440c 100644 --- a/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java +++ b/src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java @@ -25,7 +25,7 @@ import com.campus.campus.domain.council.application.exception.PrecautionNotAgreeException; import com.campus.campus.domain.council.application.exception.SignupEmailNotFoundException; import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException; -import com.campus.campus.domain.council.application.mapper.StudentCouncilMapper; +import com.campus.campus.domain.council.application.mapper.StudentCouncilLoginMapper; import com.campus.campus.domain.council.application.util.CouncilNameGenerator; import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; @@ -58,7 +58,7 @@ public class CouncilLoginService { private final SchoolRepository schoolRepository; private final CollegeRepository collegeRepository; private final MajorRepository majorRepository; - private final StudentCouncilMapper studentCouncilMapper; + private final StudentCouncilLoginMapper studentCouncilLoginMapper; private final EmailVerificationRepository emailVerificationRepository; private final JwtProvider jwtProvider; private final SecurityConfig securityConfig; @@ -90,7 +90,7 @@ public void signUp(StudentCouncilSignUpRequest studentCouncilSignUpRequest) { CouncilScope scope = validateCouncilScope(studentCouncilSignUpRequest, school); - StudentCouncil studentCouncil = studentCouncilMapper.createStudentCouncil( + StudentCouncil studentCouncil = studentCouncilLoginMapper.createStudentCouncil( studentCouncilSignUpRequest, school, scope.college, scope.major); String councilName = councilNameGenerator.buildCouncilName(studentCouncil); @@ -122,7 +122,7 @@ public StudentCouncilLoginResponse login(StudentCouncilLoginRequest studentCounc redisTokenService.setRefreshToken("COUNCIL", String.valueOf(studentCouncil.getId()), refreshToken, refreshTokenExpirationSeconds); - return studentCouncilMapper.toStudentCouncilLoginResponse(studentCouncil, accessToken, refreshToken); + return studentCouncilLoginMapper.toStudentCouncilLoginResponse(studentCouncil, accessToken, refreshToken); } @Transactional @@ -139,7 +139,7 @@ public StudentCouncilFindIdResponse findId(String email) { emailVerification.use(); - return studentCouncilMapper.toStudentCouncilFindIdResponse(studentCouncil.getLoginId()); + return studentCouncilLoginMapper.toStudentCouncilFindIdResponse(studentCouncil.getLoginId()); } @Transactional diff --git a/src/main/java/com/campus/campus/domain/council/application/service/CouncilService.java b/src/main/java/com/campus/campus/domain/council/application/service/CouncilService.java index f540ef64..5ef1d2d8 100644 --- a/src/main/java/com/campus/campus/domain/council/application/service/CouncilService.java +++ b/src/main/java/com/campus/campus/domain/council/application/service/CouncilService.java @@ -5,16 +5,11 @@ import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangeEmailRequest; import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangePasswordRequest; -import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangeProfileImageRequest; -import com.campus.campus.domain.council.application.dto.request.StudentCouncilNicknameRequest; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilChangeProfileImageResponse; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilNicknameResponse; import com.campus.campus.domain.council.application.exception.EmailAlreadyExistsException; import com.campus.campus.domain.council.application.exception.NewPasswordConfirmNotMatchException; import com.campus.campus.domain.council.application.exception.NewPasswordIsCurrentPasswordException; import com.campus.campus.domain.council.application.exception.PasswordNotCorrectException; import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException; -import com.campus.campus.domain.council.application.mapper.StudentCouncilMapper; import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; import com.campus.campus.domain.mail.application.exception.EmailVerificationNotFoundException; @@ -33,7 +28,6 @@ public class CouncilService { private final StudentCouncilRepository studentCouncilRepository; private final EmailVerificationRepository emailVerificationRepository; private final SecurityConfig securityConfig; - private final StudentCouncilMapper studentCouncilMapper; @Transactional public void changeEmail(Long councilId, StudentCouncilChangeEmailRequest studentCouncilChangeEmailRequest) { @@ -82,32 +76,6 @@ public void changePassword(Long councilId, studentCouncilRepository.save(studentCouncil); } - @Transactional - public StudentCouncilNicknameResponse changeCouncilNickname(Long councilId, - StudentCouncilNicknameRequest studentCouncilNicknameRequest) { - StudentCouncil studentCouncil = studentCouncilRepository - .findByIdAndManagerApprovedIsTrueAndDeletedAtIsNull(councilId) - .orElseThrow(StudentCouncilNotFoundException::new); - - studentCouncil.updateCouncilNickname(studentCouncilNicknameRequest.councilNickname()); - studentCouncilRepository.save(studentCouncil); - - return studentCouncilMapper.toStudentCouncilNicknameResponse(studentCouncil); - } - - @Transactional - public StudentCouncilChangeProfileImageResponse changeCouncilProfileImage(Long councilId, - StudentCouncilChangeProfileImageRequest studentCouncilChangeProfileImageRequest) { - StudentCouncil studentCouncil = studentCouncilRepository - .findByIdAndManagerApprovedIsTrueAndDeletedAtIsNull(councilId) - .orElseThrow(StudentCouncilNotFoundException::new); - - studentCouncil.updateCouncilProfileImage(studentCouncilChangeProfileImageRequest.councilProfileImageUrl()); - studentCouncilRepository.save(studentCouncil); - - return studentCouncilMapper.toStudentCouncilChangeProfileImageResponse(studentCouncil); - } - private EmailVerification getVerifiedChangeEmail(Long councilId, String email) { EmailVerification emailVerification = emailVerificationRepository .findTopByEmailAndVerificationTypeAndCouncilIdOrderByEmailVerificationIdDesc(email, diff --git a/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java b/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java index 3c3ed0cc..ff10f956 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java +++ b/src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java @@ -117,4 +117,4 @@ public void updateCouncilPresident(String president) { public void managerApprove() { this.managerApproved = true; } -} +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java index 9db42ddf..fb51bdcf 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java +++ b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java @@ -44,4 +44,11 @@ Optional findByIdWithDetailsAndManagerApprovedIsTrueAndDeletedAt boolean existsByEmailAndDeletedAtIsNotNull(String email); boolean existsByLoginIdAndManagerApprovedIsTrueAndDeletedAtIsNull(String loginId); + + Optional findByMajor_MajorId(Long majorId); + + Optional findByCollege_CollegeId(Long collegeId); + + Optional findBySchool_SchoolId(Long schoolId); + } diff --git a/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilController.java b/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilController.java index cf826519..8416871d 100644 --- a/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilController.java +++ b/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilController.java @@ -8,10 +8,6 @@ import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangeEmailRequest; import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangePasswordRequest; -import com.campus.campus.domain.council.application.dto.request.StudentCouncilChangeProfileImageRequest; -import com.campus.campus.domain.council.application.dto.request.StudentCouncilNicknameRequest; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilChangeProfileImageResponse; -import com.campus.campus.domain.council.application.dto.response.StudentCouncilNicknameResponse; import com.campus.campus.domain.council.application.service.CouncilService; import com.campus.campus.global.annotation.CurrentCouncilId; import com.campus.campus.global.common.response.CommonResponse; @@ -44,25 +40,4 @@ public CommonResponse changePassword(@CurrentCouncilId Long councilId, return CommonResponse.success(StudentCouncilResponseCode.CHANGE_PASSWORD_SUCCESS); } - - @PatchMapping("/change/nickname") - @Operation(summary = "학생회 닉네임 변경") - public CommonResponse changeNickname(@CurrentCouncilId Long councilId, - @Valid @RequestBody StudentCouncilNicknameRequest studentCouncilNicknameRequest) { - StudentCouncilNicknameResponse response = councilService.changeCouncilNickname(councilId, - studentCouncilNicknameRequest); - - return CommonResponse.success(StudentCouncilResponseCode.CHANGE_NICKNAME_SUCCESS, response); - } - - @PatchMapping("/change/image") - @Operation(summary = "학생회 프로필 이미지 변경") - public CommonResponse changeProfileImage( - @CurrentCouncilId Long councilId, - @Valid @RequestBody StudentCouncilChangeProfileImageRequest studentCouncilChangeProfileImageRequest) { - StudentCouncilChangeProfileImageResponse response = councilService.changeCouncilProfileImage(councilId, - studentCouncilChangeProfileImageRequest); - - return CommonResponse.success(StudentCouncilResponseCode.CHANGE_PROFILE_IMAGE_SUCCESS, response); - } } diff --git a/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilResponseCode.java b/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilResponseCode.java index 0d3814d2..305c09dd 100644 --- a/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilResponseCode.java +++ b/src/main/java/com/campus/campus/domain/council/presentation/StudentCouncilResponseCode.java @@ -18,8 +18,6 @@ public enum StudentCouncilResponseCode implements ResponseCodeInterface { FIND_PASSWORD_SUCCESS(200, HttpStatus.OK, "비밀번호 재설정에 성공했습니다."), CHANGE_EMAIL_SUCCESS(200, HttpStatus.OK, "이메일 변경에 성공했습니다."), CHANGE_PASSWORD_SUCCESS(200, HttpStatus.OK, "비밀번호 변경에 성공했습니다."), - CHANGE_NICKNAME_SUCCESS(200, HttpStatus.OK, "학생회 닉네임 변경에 성공했습니다."), - CHANGE_PROFILE_IMAGE_SUCCESS(200,HttpStatus.OK,"학생회 프로필 이미지 변경에 성공했습니다."), COUNCIL_WITHDRAW_SUCCESS(200, HttpStatus.OK, "학생회 회원탈퇴에 성공했습니다."); private final int code; diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostForUserResponse.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostForUserResponse.java index e4f6c663..4571e8a4 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostForUserResponse.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostForUserResponse.java @@ -32,8 +32,6 @@ public record GetPostForUserResponse( boolean isLiked, - boolean isEnded, - List images ) { } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java index 75110981..bc6a4bb6 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java @@ -16,7 +16,6 @@ public record PostListItemResponse( LocalDateTime endDateTime, String thumbnailImageUrl, ThumbnailIcon thumbnailIcon, - boolean liked, - boolean isEnded + boolean liked ) { } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java index efd53a06..817ad029 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java @@ -1,7 +1,6 @@ package com.campus.campus.domain.councilpost.application.mapper; import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.Collections; import java.util.List; @@ -32,11 +31,7 @@ @Component @RequiredArgsConstructor public class StudentCouncilPostMapper { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - public PostListItemResponse toPostListItemResponse(StudentCouncilPost post, boolean isLiked) { - LocalDateTime now = LocalDateTime.now(KST); - return new PostListItemResponse( post.getId(), post.getCategory(), @@ -46,8 +41,7 @@ public PostListItemResponse toPostListItemResponse(StudentCouncilPost post, bool post.isEvent() ? post.getStartDateTime() : post.getEndDateTime(), post.getThumbnailImageUrl(), post.getThumbnailIcon(), - isLiked, - post.isClosed(now) + isLiked ); } @@ -140,9 +134,7 @@ public GetPostDetailResponse toGetPostDetailResponse(StudentCouncilPost post, Li public GetPostForUserResponse toGetPostForUserResponse(StudentCouncilPost post, List images, Long currentUserId, boolean isLiked) { - LocalDateTime now = LocalDateTime.now(KST); var writer = post.getWriter(); - var builder = GetPostForUserResponse.builder() .id(post.getId()) .writerId(writer.getId()) @@ -155,7 +147,6 @@ public GetPostForUserResponse toGetPostForUserResponse(StudentCouncilPost post, .thumbnailImageUrl(post.getThumbnailImageUrl()) .thumbnailIcon(post.getThumbnailIcon()) .isLiked(isLiked) - .isEnded(post.isClosed(now)) .images(images != null ? images : Collections.emptyList()); if (post.isEvent()) { diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java index 9edae695..7577b042 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java @@ -109,21 +109,18 @@ public GetPostForUserResponse findById(Long postId, Long userId) { return studentCouncilPostMapper.toGetPostForUserResponse(post, imageUrls, userId, isLiked); } - public Page findSchoolPosts(PostCategory category, int page, int size, Long userId, - Long excludePostId) { + public Page findSchoolPosts(PostCategory category, int page, int size, Long userId, Long excludePostId) { User user = userRepository.findByIdWithAcademicInfo(userId).orElseThrow(UserNotFoundException::new); Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "startDateTime")); Page posts = studentCouncilPostRepository - .findBySchoolId(user.getSchool().getSchoolId(), category, CouncilType.SCHOOL_COUNCIL, excludePostId, - pageable); + .findBySchoolId(user.getSchool().getSchoolId(), category, CouncilType.SCHOOL_COUNCIL, excludePostId, pageable); return mapPostsWithLikes(posts, userId); } - public Page findCollegePosts(PostCategory category, int page, int size, Long userId, - Long excludePostId) { + public Page findCollegePosts(PostCategory category, int page, int size, Long userId, Long excludePostId) { User user = userRepository.findByIdWithAcademicInfo(userId).orElseThrow(UserNotFoundException::new); if (user.isProfileNotCompleted() || user.getCollege() == null) { @@ -133,14 +130,12 @@ public Page findCollegePosts(PostCategory category, int pa Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "startDateTime")); Page posts = studentCouncilPostRepository - .findByCollegeId(user.getCollege().getCollegeId(), category, CouncilType.COLLEGE_COUNCIL, excludePostId, - pageable); + .findByCollegeId(user.getCollege().getCollegeId(), category, CouncilType.COLLEGE_COUNCIL, excludePostId, pageable); return mapPostsWithLikes(posts, userId); } - public Page findMajorPosts(PostCategory category, int page, int size, Long userId, - Long excludePostId) { + public Page findMajorPosts(PostCategory category, int page, int size, Long userId, Long excludePostId) { User user = userRepository.findByIdWithAcademicInfo(userId).orElseThrow(UserNotFoundException::new); if (user.isProfileNotCompleted() || user.getMajor() == null) { diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java index 1206056d..0ce3fa0d 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java @@ -103,11 +103,4 @@ public boolean isWrittenByCouncil(Long councilId) { return writer != null && writer.getId().equals(councilId); } - public boolean isClosed(LocalDateTime now) { - if (this.category == PostCategory.EVENT) { - return this.startDateTime != null && now.toLocalDate().isAfter(this.startDateTime.toLocalDate()); - } else { - return this.endDateTime != null && this.endDateTime.isBefore(now); - } - } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java index 0783ad3a..438b4150 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java @@ -14,7 +14,7 @@ import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.domain.entity.PostCategory; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; -import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; +import com.campus.campus.domain.place.domain.entity.Place; public interface StudentCouncilPostRepository extends JpaRepository { @@ -283,63 +283,65 @@ List findPinsInBounds( @Param("now") LocalDateTime now ); - @EntityGraph(attributePaths = {"place"}) - @Query(""" - SELECT scp - FROM StudentCouncilPost scp - JOIN scp.writer sc - LEFT JOIN Review r - ON r.place = scp.place - AND r.createdAt >= :from - WHERE scp.startDateTime <= :now - AND scp.endDateTime >= :now - AND ( - (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.MAJOR_COUNCIL - AND sc.major.majorId = :majorId) - OR (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.COLLEGE_COUNCIL - AND sc.college.collegeId = :collegeId) - OR (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.SCHOOL_COUNCIL - AND sc.school.schoolId = :schoolId) - ) - AND sc.deletedAt IS NULL - GROUP BY scp - ORDER BY COUNT(r.id) DESC - """) + @EntityGraph(attributePaths = {"places"}) + @Query(value = """ + SELECT scp.* + FROM student_council_post scp + JOIN student_councils sc + ON scp.writer_id = sc.student_council_id + JOIN places p + ON scp.place_id = p.place_id + LEFT JOIN reviews r + ON r.place_id = p.place_id + AND r.created_at >= :from + WHERE scp.start_date_time <= :now + AND scp.end_date_time >= :now + AND ( + (sc.council_type =:majorType AND sc.major_id = :majorId) + OR (sc.council_type =:collegeType AND sc.college_id = :collegeId) + OR (sc.council_type =:schoolType AND sc.school_id = :schoolId) + ) + GROUP BY scp.id + ORDER BY COUNT(r.id) DESC + LIMIT 3; + + """, nativeQuery = true) List findTop3RecommendedPartnershipPlaces( @Param("majorId") Long majorId, @Param("collegeId") Long collegeId, @Param("schoolId") Long schoolId, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType, @Param("from") LocalDateTime from, - @Param("now") LocalDateTime now, - Pageable pageable + @Param("now") LocalDateTime now ); - @EntityGraph(attributePaths = {"writer", "writer.school", "writer.college", "writer.major", "place"}) @Query(""" - SELECT p FROM StudentCouncilPost p - JOIN p.writer w - JOIN p.place pl - LEFT JOIN w.school s - LEFT JOIN w.college c - LEFT JOIN w.major m - WHERE p.category = 'PARTNERSHIP' - AND p.thumbnailIcon = :icon - AND :now BETWEEN p.startDateTime AND p.endDateTime - AND w.deletedAt IS NULL - AND ( - (w.councilType = 'SCHOOL_COUNCIL' AND s.schoolId = :schoolId) - OR (w.councilType = 'COLLEGE_COUNCIL' AND c.collegeId = :collegeId) - OR (w.councilType = 'MAJOR_COUNCIL' AND m.majorId = :majorId) - ) - ORDER BY p.id DESC + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + LEFT JOIN w.major m + LEFT JOIN w.college c + LEFT JOIN w.school s + WHERE p.place.placeId = :placeId + AND p.startDateTime <= :paymentDate + AND p.endDateTime >= :paymentDate + AND ( + (w.councilType =:majorType AND m.majorId = :majorId) + OR (w.councilType =:collegeType AND c.collegeId = :collegeId) + OR (w.councilType =:schoolType AND s.schoolId = :schoolId) + ) """) - List findRandomPartnershipPlace( - @Param("schoolId") Long schoolId, - @Param("collegeId") Long collegeId, + Optional findValidPartnershipForUserScope( + @Param("placeId") Long placeId, + @Param("paymentDate") LocalDateTime paymentDate, @Param("majorId") Long majorId, - @Param("icon") ThumbnailIcon icon, - @Param("now") LocalDateTime now, - Pageable pageable + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType ); @Query(""" @@ -383,4 +385,54 @@ List findTodayEvent( @Param("endOfDay") LocalDateTime endOfDay, Pageable pageable ); + + @Query(""" + SELECT COUNT(p) > 0 + FROM StudentCouncilPost p + JOIN p.writer w + WHERE p.place = :place + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND ( + (w.councilType = :majorType AND w.major.majorId = :majorId) + OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) + OR (w.councilType = :schoolType AND w.school.schoolId = :schoolId) + ) + """) + boolean existsActiveByPlaceAndUserScope( + @Param("place") Place place, + @Param("now") LocalDateTime now, + @Param("majorType") CouncilType majorType, + @Param("majorId") Long majorId, + @Param("collegeType") CouncilType collegeType, + @Param("collegeId") Long collegeId, + @Param("schoolType") CouncilType schoolType, + @Param("schoolId") Long schoolId + ); + + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + WHERE p.place = :place + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND ( + (w.councilType = :majorType AND w.major.majorId = :majorId) + OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) + OR (w.councilType = :schoolType AND w.school.schoolId = :schoolId) + ) + ORDER BY p.endDateTime DESC + """) + Optional findActiveByPlaceAndUserScope( + @Param("place") Place place, + @Param("now") LocalDateTime now, + @Param("majorType") CouncilType majorType, + @Param("majorId") Long majorId, + @Param("collegeType") CouncilType collegeType, + @Param("collegeId") Long collegeId, + @Param("schoolType") CouncilType schoolType, + @Param("schoolId") Long schoolId + ); + } diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java new file mode 100644 index 00000000..7b76229e --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.place.application.dto.response; + +import java.util.List; + +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; + +public record PlaceDetailResponse( + Long placeId, + String placeKey, + String name, + String category, + String address, + Double latitude, + Double longitude, + boolean isLiked, + double star, + double distance, + + //PlaceImg 이미지 받아오기 + List imgUrls, + List reviews, + int reviewSize +) implements PlaceDetailView { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java new file mode 100644 index 00000000..a1a3f9c8 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.place.application.dto.response; + +public interface PlaceDetailView { + + Long placeId(); + + String placeKey(); + + String name(); + + String category(); + + String address(); + + Double latitude(); + + Double longitude(); + + boolean isLiked(); + + double star(); + + double distance(); +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendNearByPlaceResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendNearByPlaceResponse.java deleted file mode 100644 index 6d38c3da..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendNearByPlaceResponse.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.campus.campus.domain.place.application.dto.response; - -import java.util.List; - -import com.campus.campus.domain.place.domain.entity.Coordinate; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -public record RecommendNearByPlaceResponse( - @Schema(description = "해당 장소명", example = "숙명여자대학교") - @NotBlank - String placeName, - - @Schema(description = "장소 식별 고유 ID") - @NotBlank - String placeKey, - - @Schema(description = "장소 주소", example = "서울특별시 용산구 청파로47길 99") - @NotBlank - String address, - - @Schema(description = "장소 카테고리", example = "교육,학문>대학교") - @NotBlank - String category, - - @Schema(description = "장소 상세 정보 네이버 페이지 하이퍼링크", example = "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90+%EC%A0%9C1%EC%BA%A0%ED%8D%BC%EC%8A%A4?c=37.545947,126.964578,15,0,0,0,dh") - String link, - - @Schema(description = "전화번호", example = "010-1234-1234") - String telephone, - - @Schema(description = "위도/경도") - Coordinate coordinate, - - @Schema(description = "이미지 url") - List imgUrls -) { -} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPartnershipPlaceResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPartnershipPlaceResponse.java deleted file mode 100644 index d008e3e6..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPartnershipPlaceResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.campus.campus.domain.place.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record RecommendPartnershipPlaceResponse( - @Schema(description = "장소 ID", example = "10") - Long placeId, - - @Schema(description = "장소 이름", example = "봉구스밥버거 중앙대후문점") - String placeName, - - @Schema(description = "제휴한 학생회 이름", example = "가천대학교 총학생회") - String councilName, - - @Schema(description = "제휴 이름 (게시글 제목)", example = "전 메뉴 10% 할인") - String partnershipTitle, - - @Schema(description = "장소 주소", example = "서울특별시 동작구 상도1동") - String address, - - @Schema(description = "제휴 썸네일 이미지", example = "https://cdn.example.com/image.jpg") - String imageUrl -) { -} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPlaceByTimeResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPlaceByTimeResponse.java deleted file mode 100644 index ebf77e19..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/RecommendPlaceByTimeResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.campus.campus.domain.place.application.dto.response; - -import java.util.List; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record RecommendPlaceByTimeResponse( - @Schema(description = "추천 타입 (LUNCH: 점심, CAFE: 카페, NONE: 해당 시간 아님)", example = "LUNCH") - String type, - - @Schema(description = "추천 제휴 게시글 (최대 2개)") - List partnershipPosts, - - @Schema(description = "추천 주변 장소 (최대 2개)") - List nearbyPlaces -) { -} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java index f2ff9353..a095b140 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record SavedPlaceInfo( @@ -32,6 +33,7 @@ public record SavedPlaceInfo( String telephone, @Schema(description = "위도/경도") + @NotNull Coordinate coordinate, @Schema(description = "이미지 url") diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPartnershipInfoResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPartnershipInfoResponse.java deleted file mode 100644 index 51303aa2..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPartnershipInfoResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.campus.campus.domain.place.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record SearchPartnershipInfoResponse( - @Schema(description = "학생회 이름", example = "가천대학교 총학생회") - String councilName, - - @Schema(description = "제휴 제목", example = "전 메뉴 10% 할인") - String partnershipTitle -) { -} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPlaceInfoResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPlaceInfoResponse.java deleted file mode 100644 index 5b25c2c4..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/SearchPlaceInfoResponse.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.campus.campus.domain.place.application.dto.response; - -import java.util.List; - -import com.campus.campus.domain.place.domain.entity.Coordinate; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -public record SearchPlaceInfoResponse( - @Schema(description = "해당 장소명", example = "숙명여자대학교") - @NotBlank - String placeName, - - @Schema(description = "장소 식별 고유 ID") - @NotBlank - String placeKey, - - @Schema(description = "장소 주소", example = "서울특별시 용산구 청파로47길 99") - @NotBlank - String address, - - @Schema(description = "장소 카테고리", example = "교육,학문>대학교") - @NotBlank - String category, - - @Schema(description = "장소 상세 정보 네이버 페이지 하이퍼링크") - String link, - - @Schema(description = "전화번호", example = "010-1234-1234") - String telephone, - - @Schema(description = "위도/경도") - Coordinate coordinate, - - @Schema(description = "이미지 url") - List imgUrls, - - @Schema(description = "현재 사용자의 좋아요 여부", accessMode = Schema.AccessMode.READ_ONLY) - boolean isLiked, - - @Schema(description = "해당 장소와 제휴된 학생회 정보 목록", accessMode = Schema.AccessMode.READ_ONLY) - List partnerships, - - @Schema(description = "리뷰 평점 평균 (없으면 0.0)", example = "4.5", accessMode = Schema.AccessMode.READ_ONLY) - Double averageStar -) { -} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java similarity index 57% rename from src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java rename to src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java index 0fd54235..30c8a733 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java @@ -3,7 +3,10 @@ import java.time.LocalDate; import java.util.List; -public record PartnershipResponse( +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; + +public record PartnershipDetailResponse( Long placeId, String placeKey, String name, @@ -13,12 +16,14 @@ public record PartnershipResponse( Double longitude, String tag, //(ex.) 총학생회, 사회과학대학, IT공학과 boolean isLiked, - Double star, //리뷰 평점 + double star, //리뷰 평점 String partnerTitle, //제휴 제목 double distance, //거리(m) LocalDate endDate, //제휴 끝나는 시점 //StudentCouncilPost 이미지 받아오기 - List imgUrls -) { + List imgUrls, + List reviews, + int reviewSize +) implements PlaceDetailView { } diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java b/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java new file mode 100644 index 00000000..c6268d45 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.place.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class AlreadySuggestedPartnershipException extends ApplicationException { + public AlreadySuggestedPartnershipException() { + super(ErrorCode.ALREADY_PARTNERSHIP_SUGGESTED); + } +} diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java index a55734ea..a97848ff 100644 --- a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java @@ -16,7 +16,8 @@ public enum ErrorCode implements ErrorCodeInterface { SHA256_NOT_SUPPORTED(2603, HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256이 지원되지 않습니다."), NAVER_API_ERROR(2604, HttpStatus.INTERNAL_SERVER_ERROR, "네이버 api 호출에 실패하였습니다."), PLACE_CREATION_ERROR(2605, HttpStatus.INTERNAL_SERVER_ERROR, "Place 생성에 오류가 발생하였습니다."), - GEOCODER_ERROR(2606, HttpStatus.INTERNAL_SERVER_ERROR, "좌표 -> 주소 변환 과정에서 오류가 발생하였습니다."); + GEOCODER_ERROR(2606, HttpStatus.INTERNAL_SERVER_ERROR, "좌표 -> 주소 변환 과정에서 오류가 발생하였습니다."), + ALREADY_PARTNERSHIP_SUGGESTED(2607, HttpStatus.CONFLICT, "이미 제휴 신청 완료된 장소입니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 8e317553..dafb903d 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -6,20 +6,18 @@ import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; -import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; import com.campus.campus.domain.place.application.dto.response.LikeResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPartnershipInfoResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendNearByPlaceResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendPartnershipPlaceResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; +import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; import com.campus.campus.domain.place.domain.entity.Coordinate; import com.campus.campus.domain.place.domain.entity.LikedPlace; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.user.domain.entity.User; @Component @@ -44,23 +42,6 @@ public SavedPlaceInfo toSavedPlaceInfo(NaverSearchResponse.Item item, String pla ); } - public SearchPlaceInfoResponse toSearchPlaceInfoResponse(SavedPlaceInfo savedPlaceInfo, boolean isLiked, - List partnerships, Double averageStar) { - return new SearchPlaceInfoResponse( - savedPlaceInfo.placeName(), - savedPlaceInfo.placeKey(), - savedPlaceInfo.address(), - savedPlaceInfo.category(), - savedPlaceInfo.link(), - savedPlaceInfo.telephone(), - savedPlaceInfo.coordinate(), - savedPlaceInfo.imgUrls(), - isLiked, - partnerships, - averageStar - ); - } - public PartnershipPinResponse toPartnershipPinResponse(StudentCouncilPost post, Place place) { return new PartnershipPinResponse( post.getId(), @@ -71,9 +52,10 @@ public PartnershipPinResponse toPartnershipPinResponse(StudentCouncilPost post, ); } - public PartnershipResponse toPartnershipResponse(User user, StudentCouncilPost post, Place place, boolean isLiked, - List imgUrls, double distance) { - return new PartnershipResponse( + public PartnershipDetailResponse toPartnershipResponse(User user, StudentCouncilPost post, Place place, + boolean isLiked, + List imgUrls, double distance, Double averageStar, List reviews, Integer size) { + return new PartnershipDetailResponse( place.getPlaceId(), place.getPlaceKey(), place.getPlaceName(), @@ -83,42 +65,42 @@ public PartnershipResponse toPartnershipResponse(User user, StudentCouncilPost p place.getCoordinate().longitude(), resolveTag(post, user), isLiked, - 5.0, //리뷰 구현 이후 수정 예정 + averageStar, post.getTitle(), distance, post.getEndDateTime().toLocalDate(), - imgUrls + imgUrls, + reviews, + size ); } - public RecommendPlaceByTimeResponse toRecommendPlaceByTimeResponse(String type, - List partnershipPosts, List nearbyPlaces) { - - return new RecommendPlaceByTimeResponse(type, partnershipPosts, nearbyPlaces); - } - - public RecommendPartnershipPlaceResponse toRecommendPartnershipPlaceResponse(StudentCouncilPost post) { - return new RecommendPartnershipPlaceResponse( - post.getPlace().getPlaceId(), - post.getPlace().getPlaceName(), - post.getWriter().getCouncilName(), - post.getTitle(), - post.getPlace().getAddress(), - post.getThumbnailImageUrl() + public PlaceDetailResponse toPlaceDetailResponse(User user, Place place, boolean isLiked, + List imgUrls, double distance, Double averageStar, List reviews, Integer size) { + return new PlaceDetailResponse( + place.getPlaceId(), + place.getPlaceKey(), + place.getPlaceName(), + place.getPlaceCategory(), + place.getAddress(), + place.getCoordinate().latitude(), + place.getCoordinate().longitude(), + isLiked, + averageStar, + distance, + imgUrls, + reviews, + size ); } - public RecommendNearByPlaceResponse toRecommendNearByPlaceResponse(SavedPlaceInfo savedPlaceInfo, - List imageUrl) { - return new RecommendNearByPlaceResponse( - savedPlaceInfo.placeName(), - savedPlaceInfo.placeKey(), - savedPlaceInfo.address(), - savedPlaceInfo.category(), - savedPlaceInfo.link(), - savedPlaceInfo.telephone(), - savedPlaceInfo.coordinate(), - imageUrl + public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Place place, double averageStar) { + return new ReviewPartnerResponse( + place.getPlaceName(), + place.getPlaceCategory(), + post.getWriter().getCouncilName(), + averageStar, //리뷰 별점 + post.getTitle() ); } diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 90563e35..a9c54643 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -1,10 +1,13 @@ package com.campus.campus.domain.place.application.service; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.AbstractMap; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -23,10 +26,14 @@ import com.campus.campus.domain.councilpost.domain.repository.PostImageRepository; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; +import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; +import com.campus.campus.domain.place.domain.repository.PlaceRepository; +import com.campus.campus.domain.review.application.service.ReviewService; import com.campus.campus.domain.user.application.exception.UserNotFoundException; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; @@ -45,9 +52,12 @@ public class PartnershipPlaceService { private final PostImageRepository postImageRepository; private final PlaceMapper placeMapper; private final StudentCouncilPostRepository studentCouncilPostRepository; + private final ReviewService reviewService; + private final PlaceRepository placeRepository; + private final PlaceImagesRepository placeImagesRepository; @Transactional(readOnly = true) - public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, + public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, double userLng) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); @@ -94,6 +104,9 @@ public List getPartnershipPlaces(Long userId, Long cursor, .map(post -> post.getPlace().getPlaceId()) .collect(Collectors.toSet()); + Map averageStarMap = + reviewService.getAverageListOfStars(placeIds); + Map> postImageMap = postImageRepository.findAllByPostIn(targetPosts) .stream() .collect(Collectors.groupingBy( @@ -117,13 +130,18 @@ public List getPartnershipPlaces(Long userId, Long cursor, List images = postImageMap.getOrDefault(post.getId(), List.of()); boolean isLiked = likedPlaceIds.contains(post.getPlace().getPlaceId()); + double averageStar = averageStarMap.getOrDefault(post.getId(), 0.0); + return placeMapper.toPartnershipResponse( user, post, post.getPlace(), isLiked, images, - rounded + rounded, + averageStar, + null, + null ); }) .toList(); @@ -155,26 +173,133 @@ public List findPartnerInBounds(Long userId, Double minL .toList(); } - @Transactional - public PartnershipResponse getPartnershipDetail(Long postId, Long userId, double userLat, - double userLng) { + //장소 상세 조회 : 제휴 + private PartnershipDetailResponse getPartnershipDetailInternal( + StudentCouncilPost post, + User user, + double userLat, + double userLng + ) { + Place place = post.getPlace(); + + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPartnershipResponse( + user, + post, + place, + isLiked(place, user), + getImgUrls(post), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + + //장소 상세 조회 : 제휴 + public PartnershipDetailResponse getPartnershipDetail( + Long postId, + Long userId, + double userLat, + double userLng + ) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + StudentCouncilPost post = studentCouncilPostRepository.findById(postId) .orElseThrow(PostNotFoundException::new); + Place place = post.getPlace(); - if (place == null || place.getCoordinate() == null) { - throw new PlaceInfoNotFoundException(); - } + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPartnershipResponse( + user, + post, + place, + isLiked(place, user), + getImgUrls(post), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + //장소 상세 조회: 제휴X + private PlaceDetailView getNormalPlaceDetailInternal( + User user, + Place place, + double userLat, + double userLng + ) { + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPlaceDetailResponse( + user, + place, + isLiked(place, user), + getPlaceImgUrls(place), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + + @Transactional(readOnly = true) + public PlaceDetailView getPlaceDetails( + Long userId, + Long placeId, + double userLat, + double userLng + ) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); - double distanceMeter = GeoUtil.distanceMeter( - userLat, userLng, place.getCoordinate().latitude(), place.getCoordinate().longitude() + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); + + Optional activePost = + studentCouncilPostRepository.findActiveByPlaceAndUserScope( + place, + LocalDateTime.now(), + CouncilType.MAJOR_COUNCIL, + user.getMajor().getMajorId(), + CouncilType.COLLEGE_COUNCIL, + user.getCollege().getCollegeId(), + CouncilType.SCHOOL_COUNCIL, + user.getSchool().getSchoolId() + ); + + if (activePost.isPresent()) { + StudentCouncilPost post = activePost.get(); + return getPartnershipDetailInternal(post, user, userLat, userLng); + } else { + return getNormalPlaceDetailInternal(user, place, userLat, userLng); + } + } + + private double calculateDistance(Place place, double lat, double lng) { + double distance = GeoUtil.distanceMeter( + lat, + lng, + place.getCoordinate().latitude(), + place.getCoordinate().longitude() ); - double rounded = Math.round(distanceMeter * 100.0) / 100.0; + return Math.round(distance * 100.0) / 100.0; + } - return placeMapper.toPartnershipResponse(user, post, place, isLiked(place, user), getImgUrls(post), rounded); + private double calculateAverageStar(Place place) { + return BigDecimal.valueOf( + reviewService.getAverageOfStars(place.getPlaceId()) + ) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); } private boolean isLiked(Place place, User user) { @@ -185,6 +310,10 @@ private List getImgUrls(StudentCouncilPost post) { return postImageRepository.findImageUrlsByPost(post); } + private List getPlaceImgUrls(Place place) { + return placeImagesRepository.findImageUrlsByPlace(place); + } + private void validateAcademicInfo(User user) { if (user.getSchool() == null || user.getCollege() == null || user.getMajor() == null) { throw new AcademicInfoNotSetException(); diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index c104124f..43a032c0 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -2,54 +2,45 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.ZoneId; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.campus.campus.domain.councilpost.application.exception.AcademicInfoNotSetException; -import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; -import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; -import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; import com.campus.campus.domain.place.application.dto.response.LikeResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendNearByPlaceResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendPartnershipPlaceResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.application.dto.response.SearchCandidateResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPartnershipInfoResponse; import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; +import com.campus.campus.domain.place.application.exception.AlreadySuggestedPartnershipException; import com.campus.campus.domain.place.application.exception.NaverMapAPIException; import com.campus.campus.domain.place.application.exception.PlaceCreationException; import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.application.util.PlaceKeyGenerator; import com.campus.campus.domain.place.domain.entity.Coordinate; +import com.campus.campus.domain.place.domain.entity.CouncilPartnershipSuggestion; import com.campus.campus.domain.place.domain.entity.LikedPlace; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; +import com.campus.campus.domain.place.domain.entity.UserPartnershipSuggestion; import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; +import com.campus.campus.domain.place.domain.repository.PartnershipSuggestionRepository; import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; import com.campus.campus.domain.place.domain.repository.PlaceRepository; +import com.campus.campus.domain.place.domain.repository.UserPartnershipSuggestionRepository; import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.domain.place.infrastructure.google.GooglePlaceClient; import com.campus.campus.domain.place.infrastructure.naver.NaverMapClient; -import com.campus.campus.domain.review.domain.repository.ReviewRepository; import com.campus.campus.domain.user.application.exception.UserNotFoundException; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; @@ -65,31 +56,21 @@ @Slf4j public class PlaceService { - private static final LocalTime LUNCH_START = LocalTime.of(11, 30); - private static final LocalTime LUNCH_END = LocalTime.of(14, 0); - private static final LocalTime CAFE_START = LocalTime.of(14, 0); - private static final LocalTime CAFE_END = LocalTime.of(17, 0); - private static final LocalTime DINNER_START = LocalTime.of(17, 0); - private static final LocalTime DINNER_END = LocalTime.of(20, 0); - private static final LocalTime BAR_START = LocalTime.of(20, 0); - private static final LocalTime BAR_END = LocalTime.of(23, 30); - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final NaverMapClient naverMapClient; private final PlaceMapper placeMapper; private final PlaceRepository placeRepository; private final GooglePlaceClient googleClient; - private final StudentCouncilPostRepository studentCouncilPostRepository; private final PlaceImagesRepository placeImagesRepository; private final PresignedUrlService presignedUrlService; - private final RedisPlaceCacheService redisPlaceCacheService; private final LikedPlacesRepository likedPlacesRepository; private final UserRepository userRepository; private final ExecutorService executorService; private final GeoCoderClient geoCoderClient; - private final ReviewRepository reviewRepository; + private final PartnershipSuggestionRepository partnershipSuggestionRepository; + private final StudentCouncilRepository studentCouncilRepository; + private final UserPartnershipSuggestionRepository userPartnershipSuggestionRepository; - public List searchByLocationAndKeyword(double lat, double lng, String keyword, int imageLimit) { + public List searchByLocationAndKeyword(double lat, double lng, String keyword) { String searchWord = keyword; try { @@ -108,53 +89,14 @@ public List searchByLocationAndKeyword(double lat, double lng, S //네이버에서 특정 장소 기본정보 받아오기 NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(searchWord, 5); - return processSearchResults(naverSearchResponse, imageLimit); - } - - public List searchByLocationAndKeywordWithInfo(Long userId, double lat, double lng, - String keyword, int imageLimit) { - List basicResults = searchByLocationAndKeyword(lat, lng, keyword, imageLimit); - - if (basicResults.isEmpty()) { - return List.of(); - } - - List placeKeys = basicResults.stream() - .map(SavedPlaceInfo::placeKey) - .toList(); - - Set likedKeys = (userId != null) - ? likedPlacesRepository.findLikedPlaceKeys(userId, placeKeys) - : Collections.emptySet(); - - Map starMap = reviewRepository.findAverageStarsByPlaceKeys(placeKeys).stream() - .collect(Collectors.toMap( - obj -> (String)obj[0], - obj -> (Double)obj[1] - )); - Map> partnershipMap = studentCouncilPostRepository - .findActivePartnershipsByPlaceKeys(placeKeys, LocalDateTime.now(KST)).stream() - .collect(Collectors.groupingBy( - obj -> (String)obj[0], - Collectors.mapping( - obj -> new SearchPartnershipInfoResponse((String)obj[1], (String)obj[2]), - Collectors.toList() - ) - )); - - return basicResults.stream() - .map(info -> placeMapper.toSearchPlaceInfoResponse( - info, likedKeys.contains(info.placeKey()), partnershipMap.getOrDefault(info.placeKey(), List.of()), - Math.round(starMap.getOrDefault(info.placeKey(), 0.0) * 10.0) / 10.0 - )) - .toList(); + return processSearchResults(naverSearchResponse); } - public List searchByKeyword(String keyword, int imageLimit) { + public List searchByKeyword(String keyword) { NaverSearchResponse naverSearchResponse = naverMapClient.searchPlaces(keyword, 5); - return processSearchResults(naverSearchResponse, imageLimit); + return processSearchResults(naverSearchResponse); } public Place findOrCreatePlace(SavedPlaceInfo place) { @@ -176,6 +118,41 @@ public Place findOrCreatePlace(SavedPlaceInfo place) { }); } + //제휴 신청 + @Transactional + public void suggestPartnership(Long userId, SavedPlaceInfo placeInfo) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Place place = findOrCreatePlace(placeInfo); + + //이미 신청했는지 체크 + if (userPartnershipSuggestionRepository + .existsByUserAndPlace(user, place)) { + throw new AlreadySuggestedPartnershipException(); + } + + //중복 방지 저장 + userPartnershipSuggestionRepository.save( + UserPartnershipSuggestion.create(user, place) + ); + + //유저 소속 studentCouncil + List councils = resolveCouncils(user); + + // demand 조회 or 생성 + for (StudentCouncil council : councils) { + CouncilPartnershipSuggestion demand = + partnershipSuggestionRepository.findByPlaceAndCouncil(place, council) + .orElseGet(() -> + partnershipSuggestionRepository.save( + CouncilPartnershipSuggestion.create(place, council) + )); + demand.increase(); + } + + } + //장소 저장 @Transactional public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { @@ -193,25 +170,8 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { return new LikeResponse(null, false); } - //Place 엔티티 생성 - Place place; - try { - //조회 - place = placeRepository.findByPlaceKey(placeKey) - .orElseGet(() -> { - //없으면 생성 - String placeName = stripHtml(placeInfo.placeName()); - Place newPlace = placeRepository.save(placeMapper.createPlace(placeInfo)); - //신규 생성된 경우에만 이미지 저장 - migrateImagesToOci(newPlace.getPlaceKey(), placeInfo.imgUrls()); + Place place = findOrCreatePlace(placeInfo); - return newPlace; - }); - } catch (DataIntegrityViolationException e) { - //동시 생성으로 unique 제약 위반 시 다시 조회 - place = placeRepository.findByPlaceKey(placeKey) - .orElseThrow(PlaceCreationException::new); - } //likedPlace 저장 LikedPlace savedLikedPlace = placeMapper.createLikedPlace(user, place); likedPlacesRepository.save(savedLikedPlace); @@ -219,30 +179,7 @@ public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { return placeMapper.toLikeResponse(place); } - @Transactional(readOnly = true) - public RecommendPlaceByTimeResponse findRecommendations(Long userId, double lat, double lng) { - LocalTime now = LocalTime.now(KST); - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(UserNotFoundException::new); - - if (user.isProfileNotCompleted()) { - throw new AcademicInfoNotSetException(); - } - - if (isLunchTime(now)) { - return generateResponse(user, lat, lng, ThumbnailIcon.FOOD, "식당", "LUNCH"); - } else if (isCafeTime(now)) { - return generateResponse(user, lat, lng, ThumbnailIcon.CAFE, "카페", "CAFE"); - } else if (isDinnerTime(now)) { - return generateResponse(user, lat, lng, ThumbnailIcon.FOOD, "식당", "DINNER"); - } else if (isBarTime(now)) { - return generateResponse(user, lat, lng, ThumbnailIcon.BAR, "술집", "BAR"); - } else { - return placeMapper.toRecommendPlaceByTimeResponse("잠잘시간입니다.", List.of(), List.of()); - } - } - - private List processSearchResults(NaverSearchResponse naverSearchResponse, int imageLimit) { + private List processSearchResults(NaverSearchResponse naverSearchResponse) { List candidates = naverSearchResponse.items().stream() .map(item -> { String name = stripHtml(item.title()); @@ -265,7 +202,7 @@ private List processSearchResults(NaverSearchResponse naverSearc )); List> futures = candidates.stream() - .map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images, imageLimit), + .map(response -> CompletableFuture.supplyAsync(() -> convertToSavedPlaceInfo(response, images), executorService) .completeOnTimeout(fallback(response), 4, TimeUnit.SECONDS) .exceptionally(ex -> fallback(response))) @@ -275,11 +212,10 @@ private List processSearchResults(NaverSearchResponse naverSearc return futures.stream().map(CompletableFuture::join).toList(); } - private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, Map> images, - int imageLimit) { + private SavedPlaceInfo convertToSavedPlaceInfo(SearchCandidateResponse response, Map> images) { List cached = images.getOrDefault(response.placeKey(), List.of()); List placeImages = !cached.isEmpty() - ? cached : googleClient.fetchImages(response.name(), response.address(), imageLimit); + ? cached : googleClient.fetchImages(response.name(), response.address(), 3); return placeMapper.toSavedPlaceInfo(response.item(), response.name(), response.placeKey(), response.naverPlaceUrl(), placeImages == null ? List.of() : placeImages @@ -328,6 +264,30 @@ private String normalizeNaverMapLink(String link) { return null; } + private List resolveCouncils(User user) { + List councils = new ArrayList<>(); + + if (user.getMajor() != null) { + studentCouncilRepository.findByMajor_MajorId( + user.getMajor().getMajorId() + ).ifPresent(councils::add); + } + + if (user.getCollege() != null) { + studentCouncilRepository.findByCollege_CollegeId( + user.getCollege().getCollegeId() + ).ifPresent(councils::add); + } + + if (user.getSchool() != null) { + studentCouncilRepository.findBySchool_SchoolId( + user.getSchool().getSchoolId() + ).ifPresent(councils::add); + } + + return councils; + } + private void migrateImagesToOci(String placeKey, List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return; @@ -365,85 +325,4 @@ private String toStringAddress(AddressResponse nowAddress) { .orElse(null); } - private boolean isLunchTime(LocalTime now) { - return !now.isBefore(LUNCH_START) && now.isBefore(LUNCH_END); - } - - private boolean isCafeTime(LocalTime now) { - return !now.isBefore(CAFE_START) && now.isBefore(CAFE_END); - } - - private boolean isDinnerTime(LocalTime now) { - return !now.isBefore(DINNER_START) && now.isBefore(DINNER_END); - } - - private boolean isBarTime(LocalTime now) { - return !now.isBefore(BAR_START) && now.isBefore(BAR_END); - } - - private RecommendPlaceByTimeResponse generateResponse(User user, double lat, double lng, ThumbnailIcon icon, - String keyword, String type) { - List partnerships = getRandomPartnerships(user, icon); - - List externalPlaces = getRandomNearByPlaces(lat, lng, keyword); - - return placeMapper.toRecommendPlaceByTimeResponse(type, partnerships, externalPlaces); - } - - private List getRandomPartnerships(User user, ThumbnailIcon icon) { - Long schoolId = user.getSchool().getSchoolId(); - Long collegeId = user.getCollege() != null ? user.getCollege().getCollegeId() : null; - Long majorId = user.getMajor() != null ? user.getMajor().getMajorId() : null; - - int poolSize = 15; - List posts = studentCouncilPostRepository.findRandomPartnershipPlace( - schoolId, collegeId, majorId, icon, LocalDateTime.now(KST), PageRequest.of(0, poolSize) - ); - - if (posts.isEmpty()) { - return List.of(); - } - - List mutablePosts = new ArrayList<>(posts); - Collections.shuffle(mutablePosts); - - return mutablePosts.stream() - .limit(2) - .map(placeMapper::toRecommendPartnershipPlaceResponse) - .toList(); - } - - private List getRandomNearByPlaces(double lat, double lng, String keyword) { - Optional> cachedPlaces = redisPlaceCacheService.getCachedPlaces(lat, lng, keyword); - - List searchResults; - - if (cachedPlaces.isPresent()) { - searchResults = cachedPlaces.get(); - } else { - searchResults = searchByLocationAndKeyword(lat, lng, keyword, 1); - if (!searchResults.isEmpty()) { - redisPlaceCacheService.cachePlaces(keyword, lat, lng, searchResults); - } - } - - if (searchResults.isEmpty()) { - return List.of(); - } - - List mutableList = new ArrayList<>(searchResults); - Collections.shuffle(mutableList); - - return mutableList.stream() - .limit(2) - .map(info -> { - List imageUrl = (info.imgUrls() != null && !info.imgUrls().isEmpty()) - ? List.of(info.imgUrls().get(0)) - : Collections.emptyList(); - - return placeMapper.toRecommendNearByPlaceResponse(info, imageUrl); - }) - .toList(); - } - } diff --git a/src/main/java/com/campus/campus/domain/place/application/service/RedisPlaceCacheService.java b/src/main/java/com/campus/campus/domain/place/application/service/RedisPlaceCacheService.java deleted file mode 100644 index 8e124a91..00000000 --- a/src/main/java/com/campus/campus/domain/place/application/service/RedisPlaceCacheService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.campus.campus.domain.place.application.service; - -import java.time.Duration; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class RedisPlaceCacheService { - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - - private static final Duration CACHE_TTL = Duration.ofHours(1); - private static final double GRID_PRECISION = 1000.0; - - public void cachePlaces(String keyword, double lat, double lng, List places) { - if (places == null || places.isEmpty()) { - return; - } - - String key = generateKey(lat, lng, keyword); - try { - String jsonValue = objectMapper.writeValueAsString(places); - redisTemplate.opsForValue().set(key, jsonValue, CACHE_TTL); - log.info("[Redis] Cache Saved: key={}", key); - } catch (Exception e) { - log.warn("[Redis] Cache Save Failed: key={}", key, e); - } - } - - public Optional> getCachedPlaces(double lat, double lng, String keyword) { - String key = generateKey(lat, lng, keyword); - - Object value = redisTemplate.opsForValue().get(key); - if (value == null) { - return Optional.empty(); - } - - try { - String jsonValue = String.valueOf(value); - List places = objectMapper.readValue(jsonValue, new TypeReference>() { - }); - log.info("[Redis] Cache Hit: key={}", key); - return Optional.of(places); - } catch (Exception e) { - log.warn("[Redis] Cache Parsing Failed: key={}", key, e); - return Optional.empty(); - } - } - - private String generateKey(double lat, double lng, String keyword) { - double roundedLat = Math.round(lat * GRID_PRECISION) / GRID_PRECISION; - double roundedLng = Math.round(lng * GRID_PRECISION) / GRID_PRECISION; - - return String.format("places:recommend:%s:%s:%s", keyword, roundedLat, roundedLng); - } -} diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java new file mode 100644 index 00000000..cab754c4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java @@ -0,0 +1,60 @@ +package com.campus.campus.domain.place.domain.entity; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "council_partnership_suggestion") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CouncilPartnershipSuggestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_council_id", nullable = false) + private StudentCouncil council; + + private int requestCount; + + public void increase() { + this.requestCount++; + } + + public static CouncilPartnershipSuggestion create( + Place place, + StudentCouncil council + ) { + CouncilPartnershipSuggestion demand = new CouncilPartnershipSuggestion(); + demand.place = place; + demand.council = council; + demand.requestCount = 0; + return demand; + } +} + + + + diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java new file mode 100644 index 00000000..672e654b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java @@ -0,0 +1,47 @@ +package com.campus.campus.domain.place.domain.entity; + +import com.campus.campus.domain.user.domain.entity.User; +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "user_partnership_suggestion") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class UserPartnershipSuggestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + public static UserPartnershipSuggestion create(User user, Place place) { + UserPartnershipSuggestion s = new UserPartnershipSuggestion(); + s.user = user; + s.place = place; + return s; + } + +} diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java index 830b26ef..90300741 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/LikedPlacesRepository.java @@ -1,6 +1,5 @@ package com.campus.campus.domain.place.domain.repository; -import java.util.List; import java.util.Optional; import java.util.Set; @@ -28,12 +27,4 @@ Set findLikedPlaceIds( ); boolean existsByUserAndPlace(User user, Place place); - - @Query(""" - SELECT lp.place.placeKey - FROM LikedPlace lp - WHERE lp.user.id = :userId - AND lp.place.placeKey IN :placeKeys - """) - Set findLikedPlaceKeys(@Param("userId") Long userId, @Param("placeKeys") List placeKeys); } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PartnershipSuggestionRepository.java new file mode 100644 index 00000000..e0eb4b87 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PartnershipSuggestionRepository.java @@ -0,0 +1,14 @@ +package com.campus.campus.domain.place.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.place.domain.entity.CouncilPartnershipSuggestion; +import com.campus.campus.domain.place.domain.entity.Place; + +public interface PartnershipSuggestionRepository extends JpaRepository { + + Optional findByPlaceAndCouncil(Place place, StudentCouncil council); +} diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java index aa763a30..c6949efe 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java @@ -4,7 +4,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; public interface PlaceImagesRepository extends JpaRepository { @@ -12,4 +15,12 @@ public interface PlaceImagesRepository extends JpaRepository List findByPlaceKey(String placeKey); List findAllByPlaceKeyIn(Collection placeKeys); + + @Query(""" + select pi.imageUrl + from PlaceImages pi + where pi.place = :place + order by pi.placeImagesId asc + """) + List findImageUrlsByPlace(@Param("place") Place place); } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java index fad50e43..4032c1a4 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java @@ -1,9 +1,10 @@ package com.campus.campus.domain.place.domain.repository; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.campus.campus.domain.place.domain.entity.Place; @@ -12,4 +13,7 @@ public interface PlaceRepository extends JpaRepository { // placeKey 기준으로 Place 조회 Optional findByPlaceKey(String placeKey); + @Query("SELECT p.placeName FROM Place p WHERE p.placeId = :placeId") + Optional findPlaceNameById(@Param("placeId") Long placeId); + } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java new file mode 100644 index 00000000..04b7b1a6 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.place.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.entity.UserPartnershipSuggestion; +import com.campus.campus.domain.user.domain.entity.User; + +public interface UserPartnershipSuggestionRepository extends JpaRepository { + + boolean existsByUserAndPlace(User user, Place place); +} diff --git a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java index c926f220..e2453e51 100644 --- a/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java +++ b/src/main/java/com/campus/campus/domain/place/infrastructure/google/GooglePlaceClient.java @@ -51,7 +51,7 @@ public GooglePlaceClient( /* * 장소 이름 + 주소를 기준으로 google places에서 이미지 URL 목록을 가져옴 */ - public List fetchImages(String name, String address, int imageLimit) { + public List fetchImages(String name, String address, int limit) { boolean acquired = false; try { acquired = googleApiSemaphore.tryAcquire(5, TimeUnit.SECONDS); @@ -73,7 +73,7 @@ public List fetchImages(String name, String address, int imageLimit) { //imageURL 생성 List imageUrls = photoRefs.stream() - .limit(imageLimit) + .limit(3) .map(this::buildPhotoUrl) .toList(); diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java index 4a3c1b6c..bb1879c4 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java @@ -10,14 +10,13 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; -import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; -import com.campus.campus.domain.place.application.service.PartnershipPlaceService; import com.campus.campus.domain.place.application.dto.response.LikeResponse; +import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; +import com.campus.campus.domain.place.application.service.PartnershipPlaceService; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.global.annotation.CurrentUserId; @@ -40,26 +39,24 @@ public class PlaceController { @GetMapping("/search") @Operation(summary = "현위치 기반 가까운 순으로 장소 키워드 검색", description = "검색 결과 5개 검색되도록 함") - public CommonResponse> getPlaceWithLocationAndKeyword( - @Parameter(description = "검색할 키워드", example = "스타벅스") @RequestParam String keyword, - @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, - @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng - ) { - List searchResponse = placeService.searchByLocationAndKeyword(lat, lng, keyword, 3); - - return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); - } - - @GetMapping("/search/info") - @Operation(summary = "현위치 기반 가까운 순으로 장소 키워드 검색(좋아요, 제휴 정보, 평점 포함))", description = "검색 결과 5개 검색되도록 함") - public CommonResponse> getPlaceWithInfoByLocationAndKeyword( - @Parameter(description = "검색할 키워드", example = "스타벅스") @RequestParam String keyword, - @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, - @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng, - @CurrentUserId(required = false) Long userId + public CommonResponse> getPlaceInfoWithLocationAndKeyword( + @Parameter( + description = "검색할 키워드", + example = "스타벅스" + ) + @RequestParam String keyword, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng ) { - List searchResponse = placeService.searchByLocationAndKeywordWithInfo(userId, lat, lng, - keyword, 3); + List searchResponse = placeService.searchByLocationAndKeyword(lat, lng, keyword); return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); } @@ -67,15 +64,23 @@ public CommonResponse> getPlaceWithInfoByLocationA @GetMapping("/search/keyword") @Operation(summary = "키워드 기반 장소 검색") public CommonResponse> getPlaceInfoWithKeyword(@RequestParam String keyword) { - List searchResponse = placeService.searchByKeyword(keyword, 3); + List searchResponse = placeService.searchByKeyword(keyword); return CommonResponse.success(PlaceResponseCode.PLACE_SEARCH_SUCCESS, searchResponse); } @GetMapping public ResponseEntity getAddress( - @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, - @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng ) { return ResponseEntity.ok(geoCoderClient.getAddress(lat, lng)); } @@ -91,10 +96,18 @@ public CommonResponse likePlace(@Valid @RequestBody SavedPlaceInfo @GetMapping("/partnership") @Operation(summary = "리스트로 제휴 장소 전체 조회", description = "무한 스크롤 방식으로 제휴 장소 목록을 조회합니다.") - public CommonResponse> getPartnershipPlaces( + public CommonResponse> getPartnershipPlaces( @CurrentUserId Long userId, - @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, - @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng, @Parameter( description = """ 무한 스크롤 커서 값. @@ -113,13 +126,38 @@ public CommonResponse> getPartnershipPlaces( } ) @RequestParam(required = false) Long cursor, - @Parameter(description = "한 번에 조회할 개수", example = "5") @RequestParam(defaultValue = "5") int size) { - List response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, lat, + @Parameter( + description = "한 번에 조회할 개수", + example = "5" + ) + @RequestParam(defaultValue = "5") int size) { + List response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, + lat, lng); return CommonResponse.success(PlaceResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, response); } + @GetMapping("/detail") + @Operation(summary = "장소 세부 조회") + public CommonResponse getPlaceDetails( + @CurrentUserId Long userId, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng, + @RequestParam Long placeId + ) { + PlaceDetailView response = partnershipPlaceService.getPlaceDetails(userId, placeId, lat, lng); + return CommonResponse.success(PlaceResponseCode.GET_PLACE_DETAILS_SUCCESS, response); + } + @GetMapping("/partnership/map") @Operation( summary = "지도에서 제휴 장소 조회", @@ -130,10 +168,26 @@ public CommonResponse> getPartnershipPlaces( """) public CommonResponse> getPartnershipPlacesInMap( @CurrentUserId Long userId, - @Parameter(description = "지도 화면의 남쪽(최소) 위도", example = "37.497") @RequestParam Double minLat, - @Parameter(description = "지도 화면의 북쪽(최대) 위도", example = "37.512") @RequestParam Double maxLat, - @Parameter(description = "지도 화면의 서쪽(최소) 경도", example = "126.953") @RequestParam Double minLng, - @Parameter(description = "지도 화면의 동쪽(최대) 경도", example = "126.982") @RequestParam Double maxLng + @Parameter( + description = "지도 화면의 남쪽(최소) 위도", + example = "37.497" + ) + @RequestParam Double minLat, + @Parameter( + description = "지도 화면의 북쪽(최대) 위도", + example = "37.512" + ) + @RequestParam Double maxLat, + @Parameter( + description = "지도 화면의 서쪽(최소) 경도", + example = "126.953" + ) + @RequestParam Double minLng, + @Parameter( + description = "지도 화면의 동쪽(최대) 경도", + example = "126.982" + ) + @RequestParam Double maxLng ) { return CommonResponse.success( PlaceResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, @@ -143,10 +197,16 @@ public CommonResponse> getPartnershipPlacesInMap( @GetMapping("/partnership/detail") @Operation(summary = "제휴 장소 상세 조회(맵에서 핀 클릭 시)") - public CommonResponse getPartnershipPlaceDetail( - @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, - @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng, - @Parameter(description = "제휴글 ID", example = "10") @RequestParam Long postId, + public CommonResponse getPartnershipPlaceDetail( + @Parameter(description = "현재 위치의 위도", example = "37.50415") + @RequestParam double lat, + + @Parameter(description = "현재 위치의 경도", example = "126.9570") + @RequestParam double lng, + + @Parameter(description = "제휴글 ID", example = "10") + @RequestParam Long postId, + @CurrentUserId Long userId ) { return CommonResponse.success( @@ -154,15 +214,14 @@ public CommonResponse getPartnershipPlaceDetail( partnershipPlaceService.getPartnershipDetail(postId, userId, lat, lng)); } - @GetMapping("/random") - @Operation(summary = "시간대별 랜덤 장소 추천 (제휴 장소 2, 랜덤 장소 2) (홈화면)") - public CommonResponse getRandomPlaceByTime( + @PostMapping("/suggest-partnership") + @Operation(summary = "제휴 신청하기") + public CommonResponse suggestPartnership( @CurrentUserId Long userId, - @RequestParam double lat, - @RequestParam double lng + @Valid @RequestBody SavedPlaceInfo placeInfo ) { - RecommendPlaceByTimeResponse response = placeService.findRecommendations(userId, lat, lng); - - return CommonResponse.success(PlaceResponseCode.GET_RANDOM_PLACE_SUCCESS, response); + placeService.suggestPartnership(userId, placeInfo); + return CommonResponse.success(PlaceResponseCode.PARTNERSHIP_SUGGEST_SUCCESS); } + } diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java index b4a52f8f..9c0040dd 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java @@ -15,7 +15,8 @@ public enum PlaceResponseCode implements ResponseCodeInterface { CHECK_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 조회가 완료되었습니다."), CHECK_PARTNERSHIP_PLACES_SUCCESS(200, HttpStatus.OK, "제휴 장소 리스트 조회가 완료되었습니다."), CHECK_ONE_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 단건 조회가 완료되었습니다."), - GET_RANDOM_PLACE_SUCCESS(200, HttpStatus.OK, "시간대별 랜덤 장소 조회가 완료되었습니다."); + PARTNERSHIP_SUGGEST_SUCCESS(200, HttpStatus.OK, "제휴 신청이 완료되었어요."), + GET_PLACE_DETAILS_SUCCESS(200, HttpStatus.OK, "장소 단건 상세 조회가 완료되었어요."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java index 5d6c11b2..91eefa5f 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java @@ -6,21 +6,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record ReviewRequest( @NotNull - @Size(min = 10, message = "리뷰 내용은 최소 10자 이상이어야 합니다.") + @Size(min = 20, message = "리뷰 내용은 최소 20자 이상이어야 합니다.") @Schema(example = "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼮꼬꼬꼭 가세요.") String content, @NotNull - @DecimalMin(value = "0.0", inclusive = true) - @DecimalMax(value = "5.0", inclusive = true) @Schema(example = "3.5") Double star, @@ -45,7 +41,10 @@ public record ReviewRequest( description = "/search API에서 반환된 결과 중 하나를 선택") @NotNull @Valid - SavedPlaceInfo place + SavedPlaceInfo place, + + @Schema(example = "true") + boolean isVerified ) { } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java new file mode 100644 index 00000000..d00daae5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response; + +public record PlaceStarAvgRow( + Long placeId, + Double avgStar +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java index 26f12bd4..28d0e34c 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java @@ -4,7 +4,7 @@ @Builder public record ReviewCreateResponse( - WriteReviewResponse review, + ReviewResponse review, ReviewCreateResult result, ReviewRankingResponse ranking diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java index 4e646eb4..2fdba563 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java @@ -6,6 +6,6 @@ public record ReviewCreateResult( boolean isFirstReviewOfPlace, int userReviewCountOfPlace, - int numberOfUserStamp + String message ) { } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java new file mode 100644 index 00000000..97a28384 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.review.application.dto.response; + +public record ReviewPartnerResponse( + String placeName, + String placeCategory, + String council, + double star, + String title +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java index 31ff73f5..c0a4a0b1 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java @@ -13,7 +13,7 @@ public record ReviewResponse( LocalDate createDate, Long placeId, String content, - Double star, + double star, List imageUrls ) { } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java new file mode 100644 index 00000000..80dedb36 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record SimpleReviewResponse( + double star, + String writerName, + String content, + String thumbnailImgUrl +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java deleted file mode 100644 index 0105110f..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response; - -import java.time.LocalDate; - -import lombok.Builder; - -@Builder -public record WriteReviewResponse( - Long id, - Long userId, - String userName, - LocalDate createDate, - Long placeId, - String content, - Double star, - String imageUrl -) { -} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java new file mode 100644 index 00000000..2f52b44a --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ImageResult( + ReceiptWrapper receipt +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java new file mode 100644 index 00000000..cb23e7b0 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record PaymentInfo( + TextField date, + TotalPrice totalPrice +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java new file mode 100644 index 00000000..58903aac --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record PriceInfo( + TextField price, + TextField unitPrice +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java new file mode 100644 index 00000000..ae252725 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptItemDto( + String name, + String price +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java new file mode 100644 index 00000000..a536d3a4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptOcrItem( + TextField name, + PriceInfo price +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java new file mode 100644 index 00000000..b1b5d256 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record ReceiptOcrResponse( + List images +) { +} + + diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java new file mode 100644 index 00000000..6ebe7737 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java @@ -0,0 +1,11 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record ReceiptResult( + StoreInfo storeInfo, + PaymentInfo paymentInfo, + TotalPrice totalPrice, + List subResults +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java new file mode 100644 index 00000000..7b95a36c --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.time.LocalDate; +import java.util.List; + +public record ReceiptResultDto( + String storeName, + String totalPlace, + LocalDate paymentDate, + List items +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java new file mode 100644 index 00000000..0b6353a2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptWrapper( + ReceiptResult result +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java new file mode 100644 index 00000000..8615c915 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record StoreInfo( + TextField name +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java new file mode 100644 index 00000000..68a5636d --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java @@ -0,0 +1,8 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record SubResult( + List items +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java new file mode 100644 index 00000000..357219e2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java @@ -0,0 +1,13 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record TextField( + String text, + Formatted formatted, + Double confidenceScore +) { + public record Formatted( + String value + ) { + + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java new file mode 100644 index 00000000..e51a757f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record TotalPrice( + TextField price +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java index 88a52ff4..fd5ff7e9 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java @@ -11,8 +11,12 @@ @AllArgsConstructor public enum ErrorCode implements ErrorCodeInterface { - REVIEW_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."), - NOT_REVIEW_WRITER(2801, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."); + REVIEW_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "리뷰글을 찾을 수 없습니다."), + NOT_REVIEW_WRITER(2701, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), + RECEIPT_OCR_FAILED(2702, HttpStatus.UNPROCESSABLE_ENTITY, "OCR 인식에 실패하였습니다."), + RECEIPT_FILE_CONVERT_ERROR(2703, HttpStatus.UNPROCESSABLE_ENTITY, "영수증 FILE 형태 변형에 실패하였습니다."), + RECEIPT_FILE_TYPE_ERROR(2704, HttpStatus.UNPROCESSABLE_ENTITY, "지원하지 않는 이미지 형식입니다."), + NOT_PARTNERSHIP_RECEIPT_ERROR(2705, HttpStatus.UNPROCESSABLE_ENTITY, "영수증과 일치하는 제휴 정보를 찾을 수 없어요."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java new file mode 100644 index 00000000..2d36b5ee --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotPartnershipReceiptException extends ApplicationException { + public NotPartnershipReceiptException() { + super(ErrorCode.NOT_PARTNERSHIP_RECEIPT_ERROR); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java new file mode 100644 index 00000000..6c598f39 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptFileConvertException extends ApplicationException { + public ReceiptFileConvertException() { + super(ErrorCode.RECEIPT_FILE_CONVERT_ERROR); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java new file mode 100644 index 00000000..866b08f7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptImageFormatException extends ApplicationException { + public ReceiptImageFormatException() { + super(ErrorCode.RECEIPT_FILE_TYPE_ERROR); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java new file mode 100644 index 00000000..68a8ed33 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptOcrFailedException extends ApplicationException { + public ReceiptOcrFailedException() { + super(ErrorCode.RECEIPT_OCR_FAILED); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index 2212d316..c31a0569 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -15,7 +15,7 @@ import com.campus.campus.domain.review.application.dto.response.ReviewCreateResult; import com.campus.campus.domain.review.application.dto.response.ReviewRankingResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; -import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.review.domain.entity.Review; import com.campus.campus.domain.review.domain.entity.ReviewImage; import com.campus.campus.domain.user.domain.entity.User; @@ -32,6 +32,7 @@ public Review createReview(ReviewRequest request, User user, Place place) { .content(request.content()) .star(request.star()) .place(place) + .isVerified(request.isVerified()) .build(); } @@ -44,6 +45,15 @@ public CursorPageReviewResponse toEmptyCursorReviewResponse() { .build(); } + public SimpleReviewResponse toSimpleReviewResponse(Review review, String imageUrl) { + return SimpleReviewResponse.builder() + .star(review.getStar()) + .writerName(review.getUser().getNickname()) + .content(review.getContent()) + .thumbnailImgUrl(imageUrl) + .build(); + } + public ReviewImage createReviewImage(Review review, String imageUrl) { return ReviewImage.builder() .review(review) @@ -64,19 +74,6 @@ public ReviewResponse toReviewResponse(Review review, List imageUrls) { .build(); } - public WriteReviewResponse toWriteReviewResponse(Review review, String imageUrl) { - return WriteReviewResponse.builder() - .id(review.getId()) - .userId(review.getUser().getId()) - .userName(review.getUser().getNickname()) - .createDate(review.getCreatedAt().toLocalDate()) - .placeId(review.getPlace().getPlaceId()) - .content(review.getContent()) - .star(review.getStar()) - .imageUrl(imageUrl) - .build(); - } - public CursorPageReviewResponse toCursorReviewResponse(List items, Review last, boolean hasNext) { return CursorPageReviewResponse.builder() @@ -87,12 +84,12 @@ public CursorPageReviewResponse toCursorReviewResponse(List byte[] + byte[] imageBytes; + try { + imageBytes = file.getBytes(); + } catch (IOException e) { + throw new ReceiptFileConvertException(); + } + + //ocr + String rawResponse = clovaOcrClient.requestReceiptOcr(imageBytes, file.getOriginalFilename()); + log.info("[OCR RAW RESPONSE] {}", rawResponse); + + ReceiptOcrResponse ocrResponse = parse(rawResponse); + ReceiptResultDto result = extractReceiptResult(ocrResponse); + log.info("영수증 ocr 인식 결과:{}", result); + + return reviewService.findPartnership(placeId, result, userId); + } + + private ReceiptOcrResponse parse(String json) { + try { + log.debug("[OCR PARSE INPUT] {}", json); + return objectMapper.readValue(json, ReceiptOcrResponse.class); + } catch (Exception e) { + log.error("[OCR PARSE FAILED] raw={}", json, e); + throw new ReceiptOcrFailedException(); + } + } + + private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { + log.info("[OCR RESPONSE] images size={}", + response.images() != null ? response.images().size() : null); + //images 존재 검증 + var image = response.images().stream() + .findFirst() + .orElseThrow(() -> { + log.warn("[OCR FAILED] images empty"); + return new ReceiptOcrFailedException(); + }); + + var receipt = Optional.ofNullable(image.receipt()) + .map(ReceiptWrapper::result) + .orElseThrow(() -> { + log.warn("[OCR FAILED] receipt.result is null"); + return new ReceiptOcrFailedException(); + }); + + //상호명 + String storeName = Optional.ofNullable(receipt.storeInfo()) + .map(StoreInfo::name) + .map(TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] storeName missing"); + return new ReceiptOcrFailedException(); + }); + + //총액 + String totalPrice = Optional.ofNullable(receipt.totalPrice()) + .map(TotalPrice::price) + .map(TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] totalPrice missing, paymentInfo={}", + receipt.paymentInfo()); + return new ReceiptOcrFailedException(); + }); + + //결제일 + LocalDate paymentDate = Optional.ofNullable(receipt.paymentInfo()) + .map(PaymentInfo::date) + .map(TextField::text) + .map(text -> LocalDate.parse(text, DateTimeFormatter.BASIC_ISO_DATE)) + .orElse(null); + + //상품 목록 + List items = Optional.ofNullable(receipt.subResults()) + .orElse(List.of()) + .stream() + .flatMap(sr -> Optional.ofNullable(sr.items()).orElse(List.of()).stream()) + .map(i -> new ReceiptItemDto( + safeText(i.name()), + Optional.ofNullable(i.price()) + .map(PriceInfo::price) + .map(TextField::text) + .orElse(null) + )) + + .toList(); + log.info("[OCR ITEMS] count={}", items.size()); + + return new ReceiptResultDto( + storeName, + totalPrice, + paymentDate, + items + ); + } + + private String safeText(TextField field) { + return field != null ? field.text() : null; + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index 885fc3b2..2aa35e67 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -1,7 +1,9 @@ package com.campus.campus.domain.review.application.service; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.HashSet; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -12,19 +14,26 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException; +import com.campus.campus.domain.councilpost.application.exception.PostOciImageDeleteFailedException; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; +import com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResult; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; import com.campus.campus.domain.review.application.dto.response.ReviewRankingResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; -import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; +import com.campus.campus.domain.review.application.exception.NotPartnershipReceiptException; import com.campus.campus.domain.review.application.exception.NotUserWriterException; import com.campus.campus.domain.review.application.exception.ReviewNotFoundException; import com.campus.campus.domain.review.application.mapper.ReviewMapper; @@ -35,8 +44,6 @@ import com.campus.campus.domain.school.domain.entity.College; import com.campus.campus.domain.school.domain.entity.Major; import com.campus.campus.domain.school.domain.entity.School; -import com.campus.campus.domain.stamp.application.service.StampService; -import com.campus.campus.domain.stamp.domain.repository.StampRepository; import com.campus.campus.domain.user.application.exception.UserNotFoundException; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; @@ -57,8 +64,7 @@ public class ReviewService { private final ReviewImageRepository reviewImageRepository; private final PresignedUrlService presignedUrlService; private final StudentCouncilPostRepository studentCouncilPostRepository; - private final StampService stampService; - private final StampRepository stampRepository; + private final PlaceMapper placeMapper; @Transactional public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { @@ -69,6 +75,7 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { throw new PostImageLimitExceededException(); } + //place 객체 생성 Place place = placeService.findOrCreatePlace(request.place()); Review review = reviewMapper.createReview(request, user, place); @@ -80,18 +87,13 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { } } - // isOcrVerificationSuccess는 ocr이 성공했다고 가정하고 구현했습니다. 이는 ocr을 구현하면서 수정해주시면 됩니다. - boolean isOcrVerificationSuccess = true; - if (isOcrVerificationSuccess) { - review.verify(); - stampService.grantStampForReview(user, review); - } - - String imageUrl = - (request.imageUrls() == null || request.imageUrls().isEmpty()) - ? null : request.imageUrls().getFirst(); + List imageUrls = reviewImageRepository + .findAllByReviewOrderByIdAsc(review) + .stream() + .map(ReviewImage::getImageUrl) + .toList(); - WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); + ReviewResponse response = reviewMapper.toReviewResponse(review, imageUrls); ReviewCreateResult createResult = getCreateResult(place, user); ReviewRankingResponse rankingResponse = getRankingResult(place, user); @@ -124,7 +126,7 @@ public void delete(Long userId, Long reviewId) { List reviewImages = reviewImageRepository.findAllByReview(review); - Set deleted = new HashSet<>(); + List deleted = new ArrayList<>(); reviewImages.stream() .map(ReviewImage::getImageUrl) .forEach(deleted::add); @@ -135,14 +137,14 @@ public void delete(Long userId, Long reviewId) { for (String imageUrl : deleted) { try { presignedUrlService.deleteImage(imageUrl); - } catch (Exception e) { + } catch (PostOciImageDeleteFailedException e) { log.warn("OCI 파일 삭제 실패: {}", imageUrl, e); } } } @Transactional - public WriteReviewResponse update(Long userId, Long reviewId, ReviewRequest request) { + public ReviewResponse update(Long userId, Long reviewId, ReviewRequest request) { if (request.imageUrls() != null && request.imageUrls().size() > 10) { throw new PostImageLimitExceededException(); @@ -170,11 +172,46 @@ public WriteReviewResponse update(Long userId, Long reviewId, ReviewRequest requ cleanupUnusedImages(oldImages, request); - String imageUrl = - (request.imageUrls() == null || request.imageUrls().isEmpty()) - ? null : request.imageUrls().getFirst(); + List imageUrls = reviewImageRepository + .findAllByReview(review) + .stream() + .map(ReviewImage::getImageUrl) + .toList(); - return reviewMapper.toWriteReviewResponse(review, imageUrl); + return reviewMapper.toReviewResponse(review, imageUrls); + } + + @Transactional(readOnly = true) + public List getReviewSummaryList(Long placeId) { + + List reviews = + reviewRepository.findTop3ByPlace_PlaceIdOrderByCreatedAtDesc(placeId); + + if (reviews.isEmpty()) { + return List.of(); + } + + List reviewIds = reviews.stream() + .map(Review::getId) + .toList(); + + Map imageMap = + reviewImageRepository.findAllByReviewIdInOrderByIdAsc(reviewIds) + .stream() + .collect(Collectors.toMap( + img -> img.getReview().getId(), + ReviewImage::getImageUrl, + (existing, ignored) -> existing + )); + + return reviews.stream() + .map(review -> + reviewMapper.toSimpleReviewResponse( + review, + imageMap.get(review.getId()) + ) + ) + .toList(); } @Transactional(readOnly = true) @@ -222,12 +259,77 @@ public CursorPageReviewResponse getReviewList( ) .toList(); - Review last = reviews.getLast(); + Review last = reviews.get(reviews.size() - 1); return reviewMapper.toCursorReviewResponse(items, last, hasNext); } + @Transactional(readOnly = true) + public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto result, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + Long majorId = user.getMajor().getMajorId(); + Long collegeId = user.getCollege().getCollegeId(); + Long schoolId = user.getSchool().getSchoolId(); + + //OCR 리턴 타입보고 변경해야 함 + LocalDate paymentDateTime = result.paymentDate(); + LocalDateTime time = paymentDateTime.atStartOfDay(); //시간은 우선 임의로 + + //제휴기간 내에 결제 했는지 확인 + StudentCouncilPost post = studentCouncilPostRepository.findValidPartnershipForUserScope( + placeId, time, majorId, collegeId, schoolId, CouncilType.MAJOR_COUNCIL, + CouncilType.COLLEGE_COUNCIL, CouncilType.SCHOOL_COUNCIL + ).orElseThrow(NotPartnershipReceiptException::new); + + //review isVerified 필드 true로 변경 + + double averageStar = getAverageOfStars(placeId); + return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar); + } + + @Transactional(readOnly = true) + public double getAverageOfStars(Long placeId) { + List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); + double averageStar = reviews.stream() + .mapToDouble(Review::getStar) + .average() + .orElse(0.0); + return averageStar; + } + + @Transactional(readOnly = true) + public int getReviewCount(Long placeId) { + List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); + return reviews.size(); + } + + @Transactional(readOnly = true) + public Map getAverageListOfStars(Set placeIds) { + + if (placeIds == null || placeIds.isEmpty()) { + return Collections.emptyMap(); + } + + List rows = + reviewRepository.findAverageStarsByPlaceIds(placeIds); + + // 조회된 placeId → 평균 + Map avgMap = rows.stream() + .collect(Collectors.toMap( + PlaceStarAvgRow::placeId, + row -> row.avgStar() != null ? row.avgStar() : 0.0 + )); + + // 리뷰가 하나도 없는 placeId는 0.0으로 채움 + for (Long placeId : placeIds) { + avgMap.putIfAbsent(placeId, 0.0); + } + + return avgMap; + } + @Transactional(readOnly = true) public List readPopularPartnerships(Long userId) { User user = userRepository.findById(userId) @@ -242,9 +344,11 @@ public List readPopularPartnerships(Long userId) { user.getMajor().getMajorId(), user.getCollege().getCollegeId(), user.getSchool().getSchoolId(), + CouncilType.MAJOR_COUNCIL, + CouncilType.COLLEGE_COUNCIL, + CouncilType.SCHOOL_COUNCIL, from, - now, - PageRequest.of(0, 3) + now ); log.info("찾은 결과:{}", partnerships.stream().toList()); @@ -261,7 +365,7 @@ public List readPopularPartnerships(Long userId) { //이미지 삭제 private void cleanupUnusedImages(List oldImages, ReviewRequest request) { List newUrls = request.imageUrls() == null ? List.of() : request.imageUrls(); - Set deleteTargets = new HashSet<>(); + List deleteTargets = new ArrayList<>(); // 본문 이미지 중 제거된 이미지 oldImages.stream() @@ -284,13 +388,15 @@ private void cleanupUnusedImages(List oldImages, ReviewRequest requ } private ReviewCreateResult getCreateResult(Place place, User user) { + //해당 장소 리뷰 개수 long totalReviewCountOfPlace = reviewRepository.countByPlace_PlaceId(place.getPlaceId()); - long count = reviewRepository.countByPlaceAndUser(place, user); + //해당 장소에서 유저가 쓴 리뷰가 몇번째인지 + long userReviewCountOfPlace = reviewRepository.countByPlace_PlaceIdAndUser_Id(place.getPlaceId(), + user.getId()); boolean isFirstReviewOfPlace = totalReviewCountOfPlace == 1; - int NumberOfStamp = stampRepository.countByUser(user); - return reviewMapper.toReviewCreateResult(isFirstReviewOfPlace, count, NumberOfStamp); + return reviewMapper.toReviewCreateResult(isFirstReviewOfPlace, userReviewCountOfPlace); } diff --git a/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java b/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java index f781cc86..b1ee4482 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java @@ -15,14 +15,14 @@ import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; @Entity @Table(name = "reviews") @Getter -@Builder +@SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Review extends BaseEntity { @@ -33,7 +33,7 @@ public class Review extends BaseEntity { private String content; - private Double star; + private double star; //영수증 제휴 인증 여부 @Column(name = "is_verified") @@ -47,12 +47,11 @@ public class Review extends BaseEntity { @JoinColumn(name = "place_id", nullable = false) private Place place; - public void update(String content, Double star) { + public void update( + String content, + double star + ) { this.content = content; this.star = star; } - - public void verify() { - this.isVerified = true; - } } diff --git a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java index 08ee39c8..2f04ad3c 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java @@ -1,7 +1,5 @@ package com.campus.campus.domain.review.domain.entity; -import com.campus.campus.global.entity.BaseEntity; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -12,16 +10,16 @@ import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; @Entity -@Builder +@SuperBuilder @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class ReviewImage extends BaseEntity { +public class ReviewImage { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java index 9d7e06ca..cbbc98cf 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java @@ -2,15 +2,15 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow; import com.campus.campus.domain.review.domain.entity.Review; -import com.campus.campus.domain.user.domain.entity.User; public interface ReviewRepository extends JpaRepository { @@ -33,20 +33,29 @@ List findByPlaceIdWithCursor( ); @Query(""" - SELECT r.place.placeKey, AVG(r.star) - FROM Review r - WHERE r.place.placeKey IN :placeKeys - GROUP BY r.place.placeKey + SELECT new com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow( + r.place.placeId, + AVG(r.star) + ) + FROM Review r + WHERE r.place.placeId IN :placeIds + GROUP BY r.place.placeId """) - List findAverageStarsByPlaceKeys(@Param("placeKeys") List placeKeys); + List findAverageStarsByPlaceIds( + @Param("placeIds") Set placeIds + ); long countByPlace_PlaceId(long placeId); - long countByPlaceAndUser(Place place, User user); + long countByPlace_PlaceIdAndUser_Id(long placeId, long userId); long countByPlace_PlaceIdAndUser_Major_MajorId(long placeId, long majorId); long countByPlace_PlaceIdAndUser_College_CollegeId(Long placeId, long collegeId); long countByPlace_PlaceIdAndUser_School_SchoolId(Long placeId, long schoolId); + + List findALlByPlace_PlaceId(long placeId); + + List findTop3ByPlace_PlaceIdOrderByCreatedAtDesc(Long placeId); } diff --git a/src/main/java/com/campus/campus/domain/review/infrastructure/ocr/ClovaOcrClient.java b/src/main/java/com/campus/campus/domain/review/infrastructure/ocr/ClovaOcrClient.java new file mode 100644 index 00000000..77c13a7b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ocr/ClovaOcrClient.java @@ -0,0 +1,82 @@ +package com.campus.campus.domain.review.infrastructure.ocr; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.campus.campus.domain.review.application.exception.ReceiptImageFormatException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ClovaOcrClient { + + private final RestTemplate restTemplate; + + @Value("${clova.ocr.invoke-url}") + private String invokeUrl; + + @Value("${clova.ocr.secret-key}") + private String secretKey; + + public String requestReceiptOcr(byte[] imageBytes, String originalFilename) { + //이미지->Base64 + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + extractFormat(originalFilename); + + //json body 구성 + Map image = new HashMap<>(); + image.put("format", "png"); + image.put("data", base64Image); + image.put("name", "receipt_test2"); + + //message 파트 + Map body = new HashMap<>(); + body.put("version", "V2"); + body.put("requestId", UUID.randomUUID().toString()); + body.put("timestamp", System.currentTimeMillis()); + body.put("images", List.of(image)); + + //header + HttpHeaders headers = new HttpHeaders(); + headers.set("X-OCR-SECRET", secretKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> request = new HttpEntity<>(body, headers); + + //호출 + ResponseEntity response = + restTemplate.postForEntity(invokeUrl, request, String.class); + return response.getBody(); + } + + private String extractFormat(String originalFilename) { + if (originalFilename == null) { + throw new ReceiptImageFormatException(); + } + + String lower = originalFilename.toLowerCase(); + + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { + return "jpg"; + } + + if (lower.endsWith(".png")) { + return "png"; + } + + throw new ReceiptImageFormatException(); + } + +} diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index 6794ce85..b8e173a7 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -12,14 +12,18 @@ 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; +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; -import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.service.OcrService; import com.campus.campus.domain.review.application.service.ReviewService; import com.campus.campus.global.annotation.CurrentUserId; import com.campus.campus.global.common.response.CommonResponse; @@ -34,10 +38,12 @@ public class ReviewController { private final ReviewService reviewService; + private final OcrService ocrService; @PostMapping @Operation( summary = "리뷰 작성", + description = "영수증 리뷰 정상적으로 완료 시에 isVerified=true로 주세요!", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, content = @io.swagger.v3.oas.annotations.media.Content( @@ -82,6 +88,17 @@ public CommonResponse writeReview( return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); } + @PostMapping("/receipt-ocr") + @Operation(summary = "영수증 ocr을 통해 제휴 매장 이용 인증") + public CommonResponse upload( + @RequestPart MultipartFile file, + @Valid @RequestBody SavedPlaceInfo placeInfo, + @CurrentUserId Long userId + ) { + return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, + ocrService.processReceipt(file, userId, placeInfo)); + } + @GetMapping("/{reviewId}") @Operation(summary = "리뷰 상세 조회") public CommonResponse readReview(@PathVariable Long reviewId) { @@ -98,12 +115,12 @@ public CommonResponse deleteReview(@PathVariable Long reviewId, @CurrentUs @PatchMapping("/{reviewId}") @Operation(summary = "리뷰 수정") - public CommonResponse updateReview( + public CommonResponse updateReview( @CurrentUserId Long userId, @PathVariable Long reviewId, @RequestBody @Valid ReviewRequest request ) { - WriteReviewResponse response = reviewService.update(userId, reviewId, request); + ReviewResponse response = reviewService.update(userId, reviewId, request); return CommonResponse.success(ReviewResponseCode.REVIEW_UPDATE_SUCCESS, response); } @@ -128,4 +145,5 @@ public CommonResponse> readAllPartnerships( List response = reviewService.readPopularPartnerships(userId); return CommonResponse.success(ReviewResponseCode.GET_RANK_SUCCESS, response); } + } diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java index d18dbfed..3d2a7db1 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java @@ -16,7 +16,8 @@ public enum ReviewResponseCode implements ResponseCodeInterface { REVIEW_UPDATE_SUCCESS(200, HttpStatus.OK, "리뷰 수정이 완료되었습니다."), GET_REVIEW_LIST_SUCCESS(200, HttpStatus.OK, "리뷰 리스트 조회에 성공하였습니다."), GET_RANK_SUCCESS(200, HttpStatus.OK, "최근 한달 리뷰 순에 따른 조회 가게 조회에 성공하였습니다."), - GET_REVIEW_SUCCESS(200, HttpStatus.OK, "리뷰 상세 조회에 성공하였습니다."); + GET_REVIEW_SUCCESS(200, HttpStatus.OK, "리뷰 상세 조회에 성공하였습니다."), + OCR_SUCCESS(200, HttpStatus.OK, "OCR 인식에 성공하였습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeProfileImageRequest.java b/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeProfileImageRequest.java deleted file mode 100644 index e477e10c..00000000 --- a/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeProfileImageRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.campus.campus.domain.user.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record ChangeProfileImageRequest( - @Schema(description = "새로운 사용자 프로필 이미지", example = "http://image/img_640x640.jpg") - String newProfileImage -) { -} diff --git a/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeUserAcademicRequest.java b/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeUserAcademicRequest.java deleted file mode 100644 index 134aebda..00000000 --- a/src/main/java/com/campus/campus/domain/user/application/dto/request/ChangeUserAcademicRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.campus.campus.domain.user.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -public record ChangeUserAcademicRequest( - @Schema(description = "변경할 학교 ID", example = "1") - @NotNull - Long schoolId, - - @Schema(description = "변경할 학과 ID", example = "15") - @NotNull - Long majorId -) { -} diff --git a/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeProfileImageResponse.java b/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeProfileImageResponse.java index 40616080..7e7ef916 100644 --- a/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeProfileImageResponse.java +++ b/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeProfileImageResponse.java @@ -1,15 +1,15 @@ -package com.campus.campus.domain.user.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record ChangeProfileImageResponse( - @Schema(description = "유저 id", example = "1") - Long userId, - - @Schema(description = "유저 닉네임(campusNickname 설정했으면 campusNickname, 아니면, nickname") - String nickname, - - @Schema(description = "새로운 사용자 프로필 이미지", example = "http://image/img_640x640.jpg") - String newProfileImage -) { -} +package com.campus.campus.domain.user.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChangeProfileImageResponse( + @Schema(description = "유저 id", example = "1") + Long userId, + + @Schema(description = "유저 닉네임(campusNickname 설정했으면 campusNickname, 아니면, nickname") + String nickname, + + @Schema(description = "새로운 사용자 프로필 이미지", example = "http://image/img_640x640.jpg") + String newProfileImage +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeUserAcademicResponse.java b/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeUserAcademicResponse.java index 75c57056..eb9738b6 100644 --- a/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeUserAcademicResponse.java +++ b/src/main/java/com/campus/campus/domain/user/application/dto/response/ChangeUserAcademicResponse.java @@ -1,26 +1,26 @@ -package com.campus.campus.domain.user.application.dto.response; - -import java.time.LocalDateTime; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record ChangeUserAcademicResponse( - @Schema(description = "유저 id", example = "1") - Long userId, - - @Schema(description = "유저 닉네임(campusNickname 설정했으면 campusNickname, 아니면, nickname") - String nickname, - - @Schema(description = "변경된 학교 이름", example = "가천대학교") - String schoolName, - - @Schema(description = "변경된 단과대 이름", example = "IT융합대학") - String collegeName, - - @Schema(description = "변경된 학과 이름", example = "소프트웨어학과") - String majorName, - - @Schema(description = "다음 변경 가능 날짜", example = "2026-07-15T12:00:00") - LocalDateTime nextUpdateAvailableDate -) { -} +package com.campus.campus.domain.user.application.dto.response; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChangeUserAcademicResponse( + @Schema(description = "유저 id", example = "1") + Long userId, + + @Schema(description = "유저 닉네임(campusNickname 설정했으면 campusNickname, 아니면, nickname") + String nickname, + + @Schema(description = "변경된 학교 이름", example = "가천대학교") + String schoolName, + + @Schema(description = "변경된 단과대 이름", example = "IT융합대학") + String collegeName, + + @Schema(description = "변경된 학과 이름", example = "소프트웨어학과") + String majorName, + + @Schema(description = "다음 변경 가능 날짜", example = "2026-07-15T12:00:00") + LocalDateTime nextUpdateAvailableDate +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/application/exception/AcademicInfoUpdateRestrictionException.java b/src/main/java/com/campus/campus/domain/user/application/exception/AcademicInfoUpdateRestrictionException.java deleted file mode 100644 index 6a4c9081..00000000 --- a/src/main/java/com/campus/campus/domain/user/application/exception/AcademicInfoUpdateRestrictionException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.campus.campus.domain.user.application.exception; - -import com.campus.campus.global.common.exception.ApplicationException; - -public class AcademicInfoUpdateRestrictionException extends ApplicationException { - public AcademicInfoUpdateRestrictionException() { - super(ErrorCode.ACADEMIC_INFO_CANNOT_CHANGE); - } -} diff --git a/src/main/java/com/campus/campus/domain/user/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/user/application/exception/ErrorCode.java index 4b9ca3aa..f7193fc1 100644 --- a/src/main/java/com/campus/campus/domain/user/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/user/application/exception/ErrorCode.java @@ -14,8 +14,7 @@ public enum ErrorCode implements ErrorCodeInterface { USER_NOT_FIRST_LOGIN(2101, HttpStatus.BAD_REQUEST, "최초 로그인한 사용가 아닙니다."), NICKNAME_NOT_MATCH(2102, HttpStatus.BAD_REQUEST, "닉네임이 일치하지 않습니다."), USER_SIGNUP_FORBIDDEN_NOW(2103, HttpStatus.FORBIDDEN, "현재 유저 회원가입할 수 없는 계정입니다."), - NICKNAME_ALREADY_EXISTS(2104, HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), - ACADEMIC_INFO_CANNOT_CHANGE(2105, HttpStatus.BAD_REQUEST, "학적 정보는 6개월에 한번만 변경 가능합니다."); + NICKNAME_ALREADY_EXISTS(2104, HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java index 3fa74547..4122b8c0 100644 --- a/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java +++ b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java @@ -16,6 +16,7 @@ import com.campus.campus.domain.user.application.mapper.UserMapper; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; +import com.campus.campus.global.auth.application.dto.KakaoTokenResponse; import com.campus.campus.global.auth.application.dto.KakaoUserResponse; import com.campus.campus.global.auth.application.dto.OauthLoginResponse; import com.campus.campus.global.auth.application.mapper.LoginMapper; @@ -45,8 +46,9 @@ public class KakaoOauthService { private long refreshTokenExpirationSeconds; @Transactional - public OauthLoginResponse login(String kakaoAccessToken) { - KakaoUserResponse kakaoUser = getUserInfo(kakaoAccessToken); + public OauthLoginResponse login(String authorizationCode) { + KakaoTokenResponse kakaoToken = getToken(authorizationCode); + KakaoUserResponse kakaoUser = getUserInfo(kakaoToken.accessToken()); User user = findOrCreateUser(kakaoUser); @@ -131,4 +133,26 @@ private User findOrCreateUser(KakaoUserResponse kakaoUserResponse) { return userRepository.save(newUser); }); } -} + + private KakaoTokenResponse getToken(String authorizationCode) { + RestClient client = RestClient.create(); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", kakaoOauthProperty.getClientId()); + body.add("redirect_uri", kakaoOauthProperty.getRedirectUri()); + body.add("code", authorizationCode); + + if (kakaoOauthProperty.getClientSecret() != null && + !kakaoOauthProperty.getClientSecret().isBlank()) { + body.add("client_secret", kakaoOauthProperty.getClientSecret()); + } + + return client.post() + .uri(KAUTH_BASE_URL + "/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(KakaoTokenResponse.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/application/service/UserService.java b/src/main/java/com/campus/campus/domain/user/application/service/UserService.java index aed30792..96eb4143 100644 --- a/src/main/java/com/campus/campus/domain/user/application/service/UserService.java +++ b/src/main/java/com/campus/campus/domain/user/application/service/UserService.java @@ -1,7 +1,5 @@ package com.campus.campus.domain.user.application.service; -import java.time.LocalDateTime; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,15 +12,10 @@ import com.campus.campus.domain.school.domain.repository.MajorRepository; import com.campus.campus.domain.school.domain.repository.SchoolRepository; import com.campus.campus.domain.user.application.dto.request.CampusNicknameUpdateRequest; -import com.campus.campus.domain.user.application.dto.request.ChangeProfileImageRequest; -import com.campus.campus.domain.user.application.dto.request.ChangeUserAcademicRequest; import com.campus.campus.domain.user.application.dto.request.UserProfileRequest; -import com.campus.campus.domain.user.application.dto.response.ChangeProfileImageResponse; -import com.campus.campus.domain.user.application.dto.response.ChangeUserAcademicResponse; import com.campus.campus.domain.user.application.dto.response.UserFirstProfileResponse; import com.campus.campus.domain.user.application.dto.response.UserInfoIdsResponse; import com.campus.campus.domain.user.application.dto.response.UserInfoResponse; -import com.campus.campus.domain.user.application.exception.AcademicInfoUpdateRestrictionException; import com.campus.campus.domain.user.application.exception.NicknameAlreadyExistsException; import com.campus.campus.domain.user.application.exception.UserNotFirstLoginException; import com.campus.campus.domain.user.application.exception.UserNotFoundException; @@ -79,45 +72,6 @@ public void updateCampusNickname(Long userId, CampusNicknameUpdateRequest nickna userRepository.save(user); } - @Transactional - public ChangeProfileImageResponse updateProfileImage(Long userId, ChangeProfileImageRequest profileImageRequest) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(UserNotFoundException::new); - - user.updateProfileImage(profileImageRequest.newProfileImage()); - userRepository.save(user); - - return userMapper.toChangeProfileImageResponse(user); - } - - @Transactional - public ChangeUserAcademicResponse updateUserAcademic(Long userId, ChangeUserAcademicRequest userAcademicRequest) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(UserNotFoundException::new); - - if (user.getLastProfileUpdatedAt() != null) { - LocalDateTime nextAvailableDate = user.getLastProfileUpdatedAt().plusMonths(3); - if (LocalDateTime.now().isBefore(nextAvailableDate)) { - throw new AcademicInfoUpdateRestrictionException(); - } - } - - School school = schoolRepository.findById(userAcademicRequest.schoolId()) - .orElseThrow(SchoolNotFoundException::new); - Major major = majorRepository.findById(userAcademicRequest.majorId()) - .orElseThrow(MajorNotFoundException::new); - - if (!major.getSchool().getSchoolId().equals(school.getSchoolId())) { - throw new SchoolMajorNotSameException(); - } - - College college = major.getCollege(); - user.updateProfile(school, college, major); - userRepository.save(user); - - return userMapper.toChangeUserAcademicResponse(user); - } - public UserInfoResponse getUserInfo(Long userId) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(UserNotFoundException::new); diff --git a/src/main/java/com/campus/campus/domain/user/domain/entity/User.java b/src/main/java/com/campus/campus/domain/user/domain/entity/User.java index 8f6f1a19..f4623f88 100644 --- a/src/main/java/com/campus/campus/domain/user/domain/entity/User.java +++ b/src/main/java/com/campus/campus/domain/user/domain/entity/User.java @@ -98,4 +98,4 @@ public void updateRewardNeeded(boolean rewardNeeded) { public void updateProfileImage(String profileImage) { this.profileImage = profileImage; } -} +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java index 6b5c4f77..147fdac1 100644 --- a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java +++ b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java @@ -24,9 +24,9 @@ public class AuthController { private final KakaoOauthService kakaoOauthService; @PostMapping("/login/kakao") - @Operation(summary = "카카오 로그인 (Native App 방식)") - public CommonResponse kakaoLogin(@RequestParam("token") String kakaoAccessToken) { - OauthLoginResponse response = kakaoOauthService.login(kakaoAccessToken); + @Operation(summary = "카카오 로그인") + public CommonResponse kakaoLogin(@RequestParam("code") String code) { + OauthLoginResponse response = kakaoOauthService.login(code); return CommonResponse.success(UserResponseCode.LOGIN_SUCCESS, response); } diff --git a/src/main/java/com/campus/campus/domain/user/presentation/UserController.java b/src/main/java/com/campus/campus/domain/user/presentation/UserController.java index 6d386309..99c3c98d 100644 --- a/src/main/java/com/campus/campus/domain/user/presentation/UserController.java +++ b/src/main/java/com/campus/campus/domain/user/presentation/UserController.java @@ -7,11 +7,7 @@ import org.springframework.web.bind.annotation.RestController; import com.campus.campus.domain.user.application.dto.request.CampusNicknameUpdateRequest; -import com.campus.campus.domain.user.application.dto.request.ChangeProfileImageRequest; -import com.campus.campus.domain.user.application.dto.request.ChangeUserAcademicRequest; import com.campus.campus.domain.user.application.dto.request.UserProfileRequest; -import com.campus.campus.domain.user.application.dto.response.ChangeProfileImageResponse; -import com.campus.campus.domain.user.application.dto.response.ChangeUserAcademicResponse; import com.campus.campus.domain.user.application.dto.response.UserFirstProfileResponse; import com.campus.campus.domain.user.application.dto.response.UserInfoIdsResponse; import com.campus.campus.domain.user.application.dto.response.UserInfoResponse; @@ -47,25 +43,6 @@ public CommonResponse updateCampusNickname(@CurrentUserId Long userId, return CommonResponse.success(UserResponseCode.NICKNAME_UPDATE_SUCCESS); } - - @PatchMapping("/change/profile/image") - @Operation(summary = "사용자 프로필 이미지 변경") - public CommonResponse updateProfileImage(@CurrentUserId Long userId, - @RequestBody @Valid ChangeProfileImageRequest changeProfileImageRequest) { - ChangeProfileImageResponse response = userService.updateProfileImage(userId, changeProfileImageRequest); - - return CommonResponse.success(UserResponseCode.PROFILE_IMAGE_UPDATE_SUCCESS, response); - } - - @PatchMapping("change/profile/academic") - @Operation(summary = "사용자 학적 정보 수정 (3개월 1회 제한)") - public CommonResponse updateAcademicInfo(@CurrentUserId Long userId, - @RequestBody @Valid ChangeUserAcademicRequest changeUserAcademicRequest) { - ChangeUserAcademicResponse response = userService.updateUserAcademic(userId, changeUserAcademicRequest); - - return CommonResponse.success(UserResponseCode.ACADEMIC_INFO_UPDATE_SUCCESS, response); - } - @GetMapping @Operation(summary = "사용자 정보 조회(홈 화면)") public CommonResponse getUserInfo(@CurrentUserId Long userId) { diff --git a/src/main/java/com/campus/campus/domain/user/presentation/UserResponseCode.java b/src/main/java/com/campus/campus/domain/user/presentation/UserResponseCode.java index 77d71ad3..e1a8abd9 100644 --- a/src/main/java/com/campus/campus/domain/user/presentation/UserResponseCode.java +++ b/src/main/java/com/campus/campus/domain/user/presentation/UserResponseCode.java @@ -14,9 +14,7 @@ public enum UserResponseCode implements ResponseCodeInterface { FIRST_PROFILE_WRITE(200, HttpStatus.OK, " 프로필(학교 정보) 입력에 성공했습니다."), NICKNAME_UPDATE_SUCCESS(200, HttpStatus.OK, "닉네임 변경에 성공했습니다."), WITHDRAW_SUCCESS(200, HttpStatus.OK, "회원탈퇴에 성공했습니다."), - GET_USER_INFO_SUCCESS(200, HttpStatus.OK, "유저 정보 조회에 성공했습니다."), - PROFILE_IMAGE_UPDATE_SUCCESS(200, HttpStatus.OK, "유저 프로필 이미지 변경에 성공했습니다."), - ACADEMIC_INFO_UPDATE_SUCCESS(200, HttpStatus.OK, "유저 학적 정보 변경에 성공했습니다."); + GET_USER_INFO_SUCCESS(200, HttpStatus.OK, "유저 정보 조회에 성공했습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java new file mode 100644 index 00000000..8395fcde --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java @@ -0,0 +1,14 @@ +package com.campus.campus.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class ClovaOcrConfig { + + @Bean + public RestTemplate clovaOcrRestTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java index 171fc2d6..285cda5c 100644 --- a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java +++ b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java @@ -33,7 +33,8 @@ public String[] getPublicUrl() { "/storage/presigned", "/places", "/api/partnership/list", - "/api/partnership/map" + "/api/partnership/map", + "/reviews/receipt-ocr" }; } } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 2943e5ee..df7f701e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -69,4 +69,9 @@ server-uri: http://localhost:8080 firebase: credentials: - path: ${FIREBASE_CREDENTIALS_PATH} \ No newline at end of file + path: ${FIREBASE_CREDENTIALS_PATH} + +clova: + ocr: + invoke-url: https://oprno6zgu9.apigw.ntruss.com/custom/v1/49413/b8f236bbccbb9b0008d8d0485d9028faba366671ac72ccab91d2df2bb9c96001/document/receipt + secret-key: ${OCR_KEY} \ No newline at end of file