diff --git a/be/src/main/java/yeonba/be/exception/ArrowException.java b/be/src/main/java/yeonba/be/exception/ArrowException.java index 5c4563dc..08bded42 100644 --- a/be/src/main/java/yeonba/be/exception/ArrowException.java +++ b/be/src/main/java/yeonba/be/exception/ArrowException.java @@ -8,7 +8,6 @@ @AllArgsConstructor public enum ArrowException implements BaseException { - EXCEEDED_DAILY_AD_VIEWS( HttpStatus.BAD_REQUEST, "1일 광고 시청은 최대 3회입니다."), diff --git a/be/src/main/java/yeonba/be/exception/UserException.java b/be/src/main/java/yeonba/be/exception/UserException.java index 69552e49..caaca322 100644 --- a/be/src/main/java/yeonba/be/exception/UserException.java +++ b/be/src/main/java/yeonba/be/exception/UserException.java @@ -54,7 +54,11 @@ public enum UserException implements BaseException { LOWER_BOUND_LESS_THAN_OR_EQUAL_UPPER_BOUND( HttpStatus.BAD_REQUEST, - "하한 값은 상한 값보다 작거나 같아야 합니다."); + "하한 값은 상한 값보다 작거나 같아야 합니다."), + + NO_MORE_USERS_TO_RECOMMEND( + HttpStatus.BAD_REQUEST, + "더 이상 추천할 사용자가 존재하지 않습니다"); private final HttpStatus httpStatus; private final String reason; diff --git a/be/src/main/java/yeonba/be/user/controller/UserController.java b/be/src/main/java/yeonba/be/user/controller/UserController.java index 1f6f5bcf..d867e423 100644 --- a/be/src/main/java/yeonba/be/user/controller/UserController.java +++ b/be/src/main/java/yeonba/be/user/controller/UserController.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; @@ -37,28 +38,38 @@ public class UserController { private final ReportService reportService; private final UserService userService; - @Operation( - summary = "이성(다른 사용자) 목록 조회", - description = "조건에 따라 다른 사용자 프로필 목록을 조회할 수 있습니다." - ) - @ApiResponse( - responseCode = "200", - description = "이성 목록 정상 조회" - ) + @Operation(summary = "이성 목록 조회", description = "이성 목록을 조회할 수 있다.") + @ApiResponse(responseCode = "200", description = "이성 목록 정상 조회") @GetMapping("/users") - public ResponseEntity> users( - @ParameterObject UserQueryRequest request) { + public ResponseEntity> getUsers( + @RequestAttribute("userId") long userId, + @Valid @ParameterObject UserQueryRequest request) { + + UserQueryPageResponse response = userService.findUsersByQueryCondition(userId, request); return ResponseEntity .ok() - .body(new CustomResponse<>()); + .body(new CustomResponse<>(response)); } + @Operation(summary = "추천 이성 조회", description = "추천 이성을 조회할 수 있다.") + @ApiResponse(responseCode = "200", description = "추천 이성 정상 조회") + @GetMapping("/users/recommend") + public ResponseEntity> getRecommendUsers( + @RequestAttribute("userId") long userId) { + + LocalDate recommendDay = LocalDate.now(); + UserQueryPageResponse response = userService.findRecommendUsers(userId, recommendDay); + + return ResponseEntity + .ok() + .body(new CustomResponse<>(response)); + } @Operation(summary = "다른 사용자 프로필 조회", description = "다른 사용자의 프로필을 조회할 수 있습니다.") @ApiResponse(responseCode = "200", description = "사용자 프로필 정상 조회") @GetMapping("/users/{userId}") - public ResponseEntity> profile( + public ResponseEntity> getTargetUserProfile( @RequestAttribute("userId") long userId, @Parameter(description = "조회대상 사용자 ID", example = "1") @PathVariable("userId") long targetUserId) { @@ -71,7 +82,7 @@ public ResponseEntity> profile( } @Operation(summary = "즐겨찾기 등록", description = "다른 사용자를 자신의 즐겨찾기에 등록할 수 있습니다.") - @ApiResponse(responseCode = "200", description = "즐겨찾기 등록 정상 처리") + @ApiResponse(responseCode = "202", description = "즐겨찾기 등록 정상 처리") @PostMapping("/favorites/{userId}") public ResponseEntity> registerFavorite( @RequestAttribute("userId") long userId, @@ -100,7 +111,6 @@ public ResponseEntity> deleteFavorite( .body(new CustomResponse<>()); } - @Operation(summary = "사용자 신고", description = "다른 사용자를 신고할 수 있습니다.") @ApiResponse(responseCode = "200", description = "신고 정상 처리") @PostMapping("/users/{userId}/report") diff --git a/be/src/main/java/yeonba/be/user/dto/request/UserQueryRequest.java b/be/src/main/java/yeonba/be/user/dto/request/UserQueryRequest.java index 06bfd73c..9590f8d1 100644 --- a/be/src/main/java/yeonba/be/user/dto/request/UserQueryRequest.java +++ b/be/src/main/java/yeonba/be/user/dto/request/UserQueryRequest.java @@ -1,12 +1,10 @@ package yeonba.be.user.dto.request; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.Explode; import io.swagger.v3.oas.annotations.enums.ParameterIn; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.PositiveOrZero; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; @@ -14,80 +12,26 @@ @AllArgsConstructor public class UserQueryRequest { - @Parameter( - name = "type", - description = """ - 조회 기준 - - 추천 이성(선호 조건 바탕) : RECOMMEND - - 즐겨찾는 이성 : BOOKMARKED - - 나에게 관심 있는 이성(나에게 화살을 보낸 이성) : RECEIVED_ARROWS - - 나에게 화살을 보낸 이성 : SENT_ARROWS - - 검색 : SEARCH - """, - example = "RECOMMEND", - in = ParameterIn.QUERY - ) - @NotNull - private String type; - - @Parameter( - name = "page", - description = "조회할 페이지 번호, 0부터 시작", - example = "0", - in = ParameterIn.QUERY - ) - @NotNull - @PositiveOrZero - private Integer page; - - @Parameter( - name = "size", - description = "조회할 데이터 수(페이지 사이즈)", - example = "5", - in = ParameterIn.QUERY - ) - @Positive - private Integer size; - - @Parameter( - name = "area", - description = "활동 지역, 사용자 검색시 사용", - example = "서울", - in = ParameterIn.QUERY - ) - private String area; - - @Parameter( - name = "vocalRange", - description = "음역대, 사용자 검색시 사용", - example = "저음", - in = ParameterIn.QUERY - ) - private String vocalRange; - - @Parameter( - name = "age", - description = "나이 범위(하한,상한), 사용자 검색시 사용", - example = "20,25", - in = ParameterIn.QUERY, - explode = Explode.FALSE - ) - private List ages; - - @Parameter( - name = "height", - description = "키 범위(하한,상한), 사용자 검색시 사용", - example = "160,180", - in = ParameterIn.QUERY, - explode = Explode.FALSE - ) - private List heights; - - @Parameter( - name = "includePreferredAnimal", - description = "선호하는 동물상 포함 검색 여부, 사용자 검색시 사용", - example = "true", - in = ParameterIn.QUERY - ) - private Boolean includePreferredAnimal; + @Parameter( + name = "type", + description = """ + 조회 기준 + - 즐겨찾는 이성 : FAVORITES + - 나에게 관심 있는 이성(나에게 화살을 보낸 이성) : ARROW_SENDERS + - 나에게 화살을 보낸 이성 : ARROW_RECEIVERS""", + example = "RECOMMEND", + in = ParameterIn.QUERY) + @NotBlank(message = "조회 기준은 반드시 입력되어야 합니다.") + @Pattern( + regexp = "\\b(FAVORITES|ARROW_SENDERS|ARROW_RECEIVERS)\\b", + message = "조회 기준은 FAVORITES, ARROW_SENDERS, ARROW_RECEIVERS만 허용됩니다.") + private String type; + + @Parameter( + name = "page", + description = "조회할 페이지 번호, 기본 첫 페이지(0)", + example = "0", + in = ParameterIn.QUERY) + @PositiveOrZero(message = "페이지 번호는 0이상이어야 합니다.") + private Integer page; } diff --git a/be/src/main/java/yeonba/be/user/dto/response/UserQueryPageResponse.java b/be/src/main/java/yeonba/be/user/dto/response/UserQueryPageResponse.java index b389ba19..c515089f 100644 --- a/be/src/main/java/yeonba/be/user/dto/response/UserQueryPageResponse.java +++ b/be/src/main/java/yeonba/be/user/dto/response/UserQueryPageResponse.java @@ -1,47 +1,54 @@ package yeonba.be.user.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Size; import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; +import org.springframework.data.domain.Page; @Getter @AllArgsConstructor public class UserQueryPageResponse { - @Schema( - type = "array", - description = "조회된 이성(사용자) 목록" - ) - @Size(min = 3) - private List users; - - @Schema( - type = "number", - description = "조회된 전체 페이지 수", - example = "10" - ) - private Integer totalPage; - - @Schema( - type = "number", - description = "조회된 전체 데이터 수", - example = "1000" - ) - private Long totalElements; - - @Schema( - type = "boolean", - description = "첫 페이지 여부", - example = "true" - ) - private Boolean isFirstPage; - - @Schema( - type = "boolean", - description = "마지막 페이지 여부", - example = "false" - ) - private Boolean isLastPage; + @Schema( + type = "array", + description = "조회된 이성(사용자) 목록") + private List users; + + @Schema( + type = "number", + description = "조회된 전체 페이지 수", + example = "10") + private int totalPage; + + @Schema( + type = "number", + description = "조회된 전체 데이터 수", + example = "1000") + private long totalElements; + + @Schema( + type = "boolean", + description = "첫 페이지 여부", + example = "true") + @JsonProperty("isFirst") + private boolean first; + + @Schema( + type = "boolean", + description = "마지막 페이지 여부", + example = "false") + @JsonProperty("isLast") + private boolean last; + + public static UserQueryPageResponse from(Page page) { + + return new UserQueryPageResponse( + page.getContent(), + page.getTotalPages(), + page.getTotalElements(), + page.isFirst(), + page.isLast()); + } } diff --git a/be/src/main/java/yeonba/be/user/dto/response/UserQueryResponse.java b/be/src/main/java/yeonba/be/user/dto/response/UserQueryResponse.java index 4a4d4b50..5eb4bcf5 100644 --- a/be/src/main/java/yeonba/be/user/dto/response/UserQueryResponse.java +++ b/be/src/main/java/yeonba/be/user/dto/response/UserQueryResponse.java @@ -1,5 +1,6 @@ package yeonba.be.user.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -8,59 +9,70 @@ @AllArgsConstructor public class UserQueryResponse { - @Schema( - type = "number", - description = "사용자 ID", - example = "12" - ) - private Long id; + @Schema( + type = "number", + description = "사용자 ID", + example = "12") + private long id; - @Schema( - type = "string", - description = "닉네임", - example = "존잘남" - ) - private String nickname; + @Schema( + type = "string", + description = "대표 프로필 사진 URL", + example = "profilephoto/2-1") + private String profilePhotoUrl; - @Schema( - type = "number", - description = "받은 화살 수", - example = "11" - ) - private Integer receivedArrows; + @Schema( + type = "string", + description = "닉네임", + example = "존잘남") + private String nickname; - @Schema( - type = "string", - description = "달은 동물상", - example = "강아지상" - ) - private String lookAlikeAnimal; + @Schema( + type = "string", + description = "나이", + example = "22") + private int age; - @Schema( - type = "number", - description = "사진 싱크로율", - example = "80" - ) - private Integer photoSyncRate; + @Schema( + type = "number", + description = "총 받은 화살 수", + example = "11") + private int receivedArrows; - @Schema( - type = "string", - description = "활동 지역", - example = "서울" - ) - private String activityArea; + @Schema( + type = "string", + description = "달은 동물상", + example = "강아지상") + private String lookAlikeAnimal; - @Schema( - type = "number", - description = "키", - example = "180" - ) - private Integer height; + @Schema( + type = "number", + description = "사진 싱크로율", + example = "80") + private int photoSyncRate; - @Schema( - type = "string", - description = "음역대", - example = "저음" - ) - private String vocalRange; + @Schema( + type = "string", + description = "활동 지역", + example = "서울") + private String activityArea; + + @Schema( + type = "number", + description = "키", + example = "180") + private int height; + + @Schema( + type = "string", + description = "음역대", + example = "저음") + private String vocalRange; + + @Schema( + type = "boolean", + description = "즐겨찾기 여부", + example = "false") + @JsonProperty("isFavorite") + private boolean favorite; } diff --git a/be/src/main/java/yeonba/be/user/entity/User.java b/be/src/main/java/yeonba/be/user/entity/User.java index f974cb9a..a29e6a69 100644 --- a/be/src/main/java/yeonba/be/user/entity/User.java +++ b/be/src/main/java/yeonba/be/user/entity/User.java @@ -185,6 +185,7 @@ public void plusArrow(int arrow) { public void minusArrow(int arrow) { if (this.arrow < arrow) { + throw new GeneralException(ArrowException.NOT_ENOUGH_ARROW_TO_SEND); } diff --git a/be/src/main/java/yeonba/be/user/entity/UserRecommendation.java b/be/src/main/java/yeonba/be/user/entity/UserRecommendation.java index a09b1ee9..4663442b 100644 --- a/be/src/main/java/yeonba/be/user/entity/UserRecommendation.java +++ b/be/src/main/java/yeonba/be/user/entity/UserRecommendation.java @@ -1,6 +1,7 @@ package yeonba.be.user.entity; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -8,15 +9,17 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; -import lombok.AllArgsConstructor; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Table(name = "users_recommendations") @Getter @Entity -@NoArgsConstructor -@AllArgsConstructor +@EntityListeners(value = AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserRecommendation { @Id @@ -31,5 +34,12 @@ public class UserRecommendation { @JoinColumn(name = "recommended_user_id") private User recommendedUser; + @CreatedDate private LocalDateTime createdAt; + + public UserRecommendation(User user, User recommendedUser) { + + this.user = user; + this.recommendedUser = recommendedUser; + } } diff --git a/be/src/main/java/yeonba/be/user/entity/UserSearchLog.java b/be/src/main/java/yeonba/be/user/entity/UserSearchLog.java new file mode 100644 index 00000000..9e4a3409 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/entity/UserSearchLog.java @@ -0,0 +1,44 @@ +package yeonba.be.user.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +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 java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Table(name = "users_search_logs") +@Getter +@Entity +@EntityListeners(value = AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSearchLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "searched_user_id") + private User searchedUser; + + @CreatedDate + private LocalDateTime createdAt; + + public UserSearchLog(User user, User searchedUser) { + + this.user = user; + this.searchedUser = searchedUser; + } +} \ No newline at end of file diff --git a/be/src/main/java/yeonba/be/user/enums/Gender.java b/be/src/main/java/yeonba/be/user/enums/Gender.java index ce7df7c6..0b2e69f9 100644 --- a/be/src/main/java/yeonba/be/user/enums/Gender.java +++ b/be/src/main/java/yeonba/be/user/enums/Gender.java @@ -13,7 +13,6 @@ public enum Gender { public final String genderString; public final boolean genderBoolean; - public static Gender from(String genderString) { if (StringUtils.equals(genderString, MALE.genderString)) { diff --git a/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteQuery.java b/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteQuery.java index 3e2265c7..c0fb3c33 100644 --- a/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteQuery.java +++ b/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteQuery.java @@ -11,16 +11,16 @@ @RequiredArgsConstructor public class FavoriteQuery { - private final FavoriteRepository favoriteRepository; + private final FavoriteRepository favoriteRepository; - public boolean isFavoriteExist(User user, User favoriteUser) { + public boolean isFavoriteExist(User user, User favoriteUser) { - return favoriteRepository.existsByUserAndFavoriteUser(user, favoriteUser); - } + return favoriteRepository.existsByUserAndFavoriteUser(user, favoriteUser); + } - public Favorite find(User user, User favoriteUser) { + public Favorite find(User user, User favoriteUser) { - return favoriteRepository.findByUserAndFavoriteUser(user, favoriteUser) - .orElseThrow(() -> new GeneralException(FavoriteException.FAVORITE_NOT_FOUND)); - } + return favoriteRepository.findByUserAndFavoriteUser(user, favoriteUser) + .orElseThrow(() -> new GeneralException(FavoriteException.FAVORITE_NOT_FOUND)); + } } diff --git a/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteRepository.java b/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteRepository.java index 4db20dc2..b7db7459 100644 --- a/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteRepository.java +++ b/be/src/main/java/yeonba/be/user/repository/favorite/FavoriteRepository.java @@ -9,7 +9,7 @@ @Repository public interface FavoriteRepository extends JpaRepository { - boolean existsByUserAndFavoriteUser(User user, User favoriteUser); + boolean existsByUserAndFavoriteUser(User user, User favoriteUser); - Optional findByUserAndFavoriteUser(User user, User favoriteUser); + Optional findByUserAndFavoriteUser(User user, User favoriteUser); } diff --git a/be/src/main/java/yeonba/be/user/repository/user/UserQuery.java b/be/src/main/java/yeonba/be/user/repository/user/UserQuery.java index c0bfa009..877da017 100644 --- a/be/src/main/java/yeonba/be/user/repository/user/UserQuery.java +++ b/be/src/main/java/yeonba/be/user/repository/user/UserQuery.java @@ -1,9 +1,16 @@ package yeonba.be.user.repository.user; + +import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; import yeonba.be.exception.GeneralException; import yeonba.be.exception.UserException; +import yeonba.be.user.dto.response.UserQueryPageResponse; +import yeonba.be.user.dto.response.UserQueryResponse; import yeonba.be.user.entity.User; @Component @@ -24,6 +31,11 @@ public User findByPhoneNumber(String phoneNumber) { .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND)); } + public boolean validateExistsById(long userId) { + + return userRepository.existsById(userId); + } + public boolean validateUsedNickname(String nickname) { return userRepository.existsByNickname(nickname); @@ -33,4 +45,39 @@ public boolean validateUsedPhoneNumber(String phoneNumber) { return userRepository.existsByPhoneNumber(phoneNumber); } + + public UserQueryPageResponse findFavoritesBy(long userId, PageRequest pageRequest) { + + Page page = userRepository.findFavoritesBy(userId, pageRequest); + + return UserQueryPageResponse.from(page); + } + + public UserQueryPageResponse findArrowReceiversBy(long senderId, PageRequest pageRequest) { + + Page page = userRepository.findArrowReceiversBy(senderId, pageRequest); + + return UserQueryPageResponse.from(page); + } + + public UserQueryPageResponse findArrowSendersBy(long receiverId, PageRequest pageRequest) { + + Page page = userRepository.findArrowSendersBy(receiverId, pageRequest); + + return UserQueryPageResponse.from(page); + } + + public List findByIds(List userIds) { + + return userRepository.findAllById(userIds); + } + + public UserQueryPageResponse findRecommendUsers( + long userId, boolean userGender, PageRequest pageRequest, LocalDate recommendDay) { + + Page page = userRepository + .findRecommendUsers(userId, userGender, pageRequest, recommendDay); + + return UserQueryPageResponse.from(page); + } } diff --git a/be/src/main/java/yeonba/be/user/repository/user/UserRepository.java b/be/src/main/java/yeonba/be/user/repository/user/UserRepository.java index 35730b44..9343d58b 100644 --- a/be/src/main/java/yeonba/be/user/repository/user/UserRepository.java +++ b/be/src/main/java/yeonba/be/user/repository/user/UserRepository.java @@ -6,7 +6,7 @@ import yeonba.be.user.entity.User; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findByIdAndDeletedIsFalse(long userId); diff --git a/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryCustom.java b/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryCustom.java new file mode 100644 index 00000000..30ca5753 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryCustom.java @@ -0,0 +1,21 @@ +package yeonba.be.user.repository.user; + +import java.time.LocalDate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import yeonba.be.user.dto.response.UserQueryResponse; + +public interface UserRepositoryCustom { + + Page findFavoritesBy(long userId, PageRequest pageRequest); + + Page findArrowReceiversBy(long senderId, PageRequest pageRequest); + + Page findArrowSendersBy(long receiverId, PageRequest pageRequest); + + Page findRecommendUsers( + long userId, + boolean userGender, + PageRequest pageRequest, + LocalDate recommendDay); +} \ No newline at end of file diff --git a/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryImpl.java b/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryImpl.java new file mode 100644 index 00000000..be1e39c8 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/user/UserRepositoryImpl.java @@ -0,0 +1,321 @@ +package yeonba.be.user.repository.user; + +import static yeonba.be.arrow.entity.QArrowTransaction.arrowTransaction; +import static yeonba.be.mypage.entity.QAcquaintance.acquaintance; +import static yeonba.be.user.entity.QAnimal.animal; +import static yeonba.be.user.entity.QArea.area; +import static yeonba.be.user.entity.QBlock.block; +import static yeonba.be.user.entity.QFavorite.favorite; +import static yeonba.be.user.entity.QProfilePhoto.profilePhoto; +import static yeonba.be.user.entity.QUser.user; +import static yeonba.be.user.entity.QUserPreference.userPreference; +import static yeonba.be.user.entity.QUserRecommendation.userRecommendation; +import static yeonba.be.user.entity.QUserSearchLog.userSearchLog; +import static yeonba.be.user.entity.QVocalRange.vocalRange; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.support.PageableExecutionUtils; +import yeonba.be.user.dto.response.UserQueryResponse; +import yeonba.be.user.entity.UserPreference; + +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findFavoritesBy(long userId, PageRequest pageRequest) { + + int limit = pageRequest.getPageSize(); + int offset = pageRequest.getPageNumber() * limit; + + List content = selectUserQueryResponse( + Expressions.constant(true)) + .where(findFavoritesCondition(userId)) + .limit(limit) + .offset(offset) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(user.count()) + .from(user) + .where(findFavoritesCondition(userId)); + + return PageableExecutionUtils.getPage(content, pageRequest, countQuery::fetchOne); + } + + private BooleanExpression findFavoritesCondition(long userId) { + + return Expressions.allOf( + isActiveAndNotDeletedUserCondition(), + isNotBlockedUserCondition(userId), + findOneFavoriteBy(userId).exists()); + } + + @Override + public Page findArrowReceiversBy(long senderId, PageRequest pageRequest) { + + int limit = pageRequest.getPageSize(); + int offset = pageRequest.getPageNumber() * limit; + + List content = selectUserQueryResponse( + Expressions.as(findOneFavoriteBy(senderId).exists(), "favorite")) + .where(findArrowReceiversCondition(senderId)) + .limit(limit) + .offset(offset) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(user.count()) + .from(user) + .where(findArrowReceiversCondition(senderId)); + + return PageableExecutionUtils.getPage(content, pageRequest, countQuery::fetchOne); + } + + private BooleanExpression findArrowReceiversCondition(long senderId) { + + return Expressions.allOf( + isActiveAndNotDeletedUserCondition(), + isNotBlockedUserCondition(senderId), + findOneArrowSentTransactionBy(senderId).exists()); + } + + @Override + public Page findArrowSendersBy(long receiverId, PageRequest pageRequest) { + + int limit = pageRequest.getPageSize(); + int offset = pageRequest.getPageNumber() * limit; + + List content = selectUserQueryResponse( + Expressions.as(findOneFavoriteBy(receiverId).exists(), "favorite")) + .where(findArrowSendersCondition(receiverId)) + .limit(limit) + .offset(offset) + .fetch(); + + JPAQuery countQuery = queryFactory.select(user.count()) + .from(user) + .where(findArrowSendersCondition(receiverId)); + + return PageableExecutionUtils.getPage(content, pageRequest, countQuery::fetchOne); + } + + private BooleanExpression findArrowSendersCondition(long receiverId) { + + return Expressions.allOf( + isActiveAndNotDeletedUserCondition(), + isNotBlockedUserCondition(receiverId), + findOneArrowReceivedTransactionBy(receiverId).exists()); + } + + @Override + public Page findRecommendUsers( + long userId, + boolean userGender, + PageRequest pageRequest, + LocalDate recommendDay) { + + int limit = pageRequest.getPageSize(); + int offset = pageRequest.getPageNumber() * limit; + + // 추천 대상 사용자의 선호조건 조회 + UserPreference preference = queryFactory.selectFrom(userPreference) + .where(userPreference.user.id.eq(userId)) + .fetchFirst(); + + List content = selectUserQueryResponse( + Expressions.constant(false)) + .where(recommendUserCondition(userId, userGender, preference, recommendDay)) + .limit(limit) + .offset(offset) + .fetch(); + + JPAQuery countQuery = queryFactory.select(user.count()) + .from(user) + .where(recommendUserCondition(userId, userGender, preference, recommendDay)); + + return PageableExecutionUtils.getPage(content, pageRequest, countQuery::fetchOne); + } + + /* + 이성 추천시 배제되는 사용자 + - 자기 자신(조회하는 사용자) + - 동성 + - 추천(선호) 조건을 만족하지 않는 사용자 + - 화살을 주고 받은 적이 있는 사용자 + - 즐겨찾기한 사용자 + - 삭제, 휴면 상태인 사용자 + - 지인(전화번호로 구분) + - 추천 일자에 이미 추천된 사용자 + - 추천 일자에 검색된 적 있는 사용자 + */ + private BooleanExpression recommendUserCondition( + long userId, + Boolean gender, + UserPreference preference, + LocalDate recommendDay) { + + return Expressions.allOf( + user.id.ne(userId), + user.gender.ne(gender), + isActiveAndNotDeletedUserCondition(), + isNotAcquaintanceCondition(userId), + isNotBlockedUserCondition(userId), + isUserSatisfiedPreferenceCondition(preference), + findOneArrowReceivedTransactionBy(userId).notExists(), + findOneArrowSentTransactionBy(userId).notExists(), + findOneFavoriteBy(userId).notExists(), + isNotUserRecommendedOnDayCondition(userId, recommendDay), + isNotUserSearchedOnDayCondition(userId, recommendDay)); + } + + private JPQLQuery findOneArrowReceivedTransactionBy(long receiverId) { + + return JPAExpressions.selectOne() + .from(arrowTransaction) + .where( + arrowTransaction.receiver.id.eq(receiverId), + arrowTransaction.sender.id.eq(user.id)); + } + + private JPQLQuery findOneFavoriteBy(long userId) { + + return JPAExpressions.selectOne() + .from(favorite) + .where( + favorite.user.id.eq(userId), + favorite.favoriteUser.id.eq(user.id)); + } + + private JPQLQuery findOneArrowSentTransactionBy(long senderId) { + + return JPAExpressions.selectOne() + .from(arrowTransaction) + .where( + arrowTransaction.sender.id.eq(senderId), + arrowTransaction.receiver.id.eq(user.id)); + } + + private BooleanExpression isUserSatisfiedPreferenceCondition(UserPreference preference) { + + return Expressions.allOf( + user.age.between(preference.getAgeLowerBound(), preference.getAgeUpperBound()), + user.height.between(preference.getHeightLowerBound(), preference.getHeightUpperBound()), + user.mbti.eq(preference.getMbti()), + user.bodyType.eq(preference.getBodyType()), + user.vocalRange.id.eq(preference.getVocalRange().getId()), + user.area.id.eq(preference.getArea().getId()), + user.animal.id.eq(preference.getAnimal().getId())); + } + + private BooleanExpression isNotUserRecommendedOnDayCondition( + long userId, + LocalDate recommendDay) { + + LocalDateTime from = recommendDay.atStartOfDay(); + LocalDateTime to = recommendDay.atTime(LocalTime.MAX); + + return JPAExpressions.selectOne() + .from(userRecommendation) + .where( + userRecommendation.user.id.eq(userId), + userRecommendation.recommendedUser.id.eq(user.id), + userRecommendation.createdAt.between(from, to)) + .notExists(); + } + + private BooleanExpression isNotUserSearchedOnDayCondition( + long userId, + LocalDate searchDay) { + + LocalDateTime from = searchDay.atStartOfDay(); + LocalDateTime to = searchDay.atTime(LocalTime.MAX); + + return JPAExpressions.selectOne() + .from(userSearchLog) + .where( + userSearchLog.user.id.eq(userId), + userSearchLog.searchedUser.id.eq(user.id), + userSearchLog.createdAt.between(from, to)) + .notExists(); + } + + /* + 응답 dto에 필요한 필드를 select하는 공통 사용 쿼리, 별도 분리 + 경우에 따라 즐겨찾기 등록 여부(favorite)을 상수로 주입하기에 + 해당 부분만 파라미터로 받도록 구성 + */ + private JPAQuery selectUserQueryResponse( + Expression checkFavoriteExistsNestedQuery) { + + return queryFactory + .select( + Projections.constructor(UserQueryResponse.class, + user.id, + profilePhoto.photoUrl, + user.nickname, + user.age, + user.arrow, + animal.name, + user.photoSyncRate, + area.name, + user.height, + vocalRange.classification, + checkFavoriteExistsNestedQuery)) + .from(user) + .innerJoin(user.animal, animal) + .innerJoin(user.area, area) + .innerJoin(user.vocalRange, vocalRange) + .innerJoin(user.profilePhotos, profilePhoto) + .where(isRepresentativeProfilePhotoCondition()); + } + + private BooleanExpression isRepresentativeProfilePhotoCondition() { + + return profilePhoto.id.eq( + JPAExpressions.select(profilePhoto.id.min()) + .from(profilePhoto) + .where(profilePhoto.user.id.eq(user.id))); + } + + private BooleanExpression isActiveAndNotDeletedUserCondition() { + + return user.deleted.isFalse() + .and(user.inactive.isFalse()); + } + + private BooleanExpression isNotAcquaintanceCondition(long userId) { + + return JPAExpressions.selectOne() + .from(acquaintance) + .where( + acquaintance.userId.eq(userId), + acquaintance.phoneNumber.eq(user.phoneNumber)) + .notExists(); + } + + private BooleanExpression isNotBlockedUserCondition(long userId) { + + return JPAExpressions.selectOne() + .from(block) + .where( + block.user.id.eq(userId), + block.blockedUser.id.eq(user.id)) + .notExists(); + } +} \ No newline at end of file diff --git a/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationCommand.java b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationCommand.java new file mode 100644 index 00000000..3ca5c51e --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationCommand.java @@ -0,0 +1,18 @@ +package yeonba.be.user.repository.userrecommendation; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.UserRecommendation; + +@Component +@RequiredArgsConstructor +public class UserRecommendationCommand { + + private final UserRecommendationRepository userRecommendationRepository; + + public List saveAll(List userRecommendations) { + + return userRecommendationRepository.saveAll(userRecommendations); + } +} diff --git a/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationQuery.java b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationQuery.java new file mode 100644 index 00000000..4fb04d95 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationQuery.java @@ -0,0 +1,23 @@ +package yeonba.be.user.repository.userrecommendation; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import yeonba.be.user.entity.User; + +@Component +@RequiredArgsConstructor +public class UserRecommendationQuery { + + private final UserRecommendationRepository userRecommendationRepository; + + public boolean existsRecommendationForUserOnDay(User user, LocalDate recommendDay) { + + LocalDateTime from = recommendDay.atStartOfDay(); + LocalDateTime to = recommendDay.atTime(LocalTime.MAX); + + return userRecommendationRepository.existsByUserAndCreatedAtBetween(user, from, to); + } +} \ No newline at end of file diff --git a/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationRepository.java b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationRepository.java new file mode 100644 index 00000000..f73bb192 --- /dev/null +++ b/be/src/main/java/yeonba/be/user/repository/userrecommendation/UserRecommendationRepository.java @@ -0,0 +1,13 @@ +package yeonba.be.user.repository.userrecommendation; + +import java.time.LocalDateTime; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import yeonba.be.user.entity.User; +import yeonba.be.user.entity.UserRecommendation; + +@Repository +public interface UserRecommendationRepository extends JpaRepository { + + boolean existsByUserAndCreatedAtBetween(User user, LocalDateTime from, LocalDateTime to); +} diff --git a/be/src/main/java/yeonba/be/user/service/UserService.java b/be/src/main/java/yeonba/be/user/service/UserService.java index 55f66138..991c44b7 100644 --- a/be/src/main/java/yeonba/be/user/service/UserService.java +++ b/be/src/main/java/yeonba/be/user/service/UserService.java @@ -3,21 +3,29 @@ import java.time.LocalDate; import java.time.Period; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import yeonba.be.arrow.repository.ArrowQuery; import yeonba.be.exception.GeneralException; import yeonba.be.exception.JoinException; +import yeonba.be.exception.UserException; import yeonba.be.login.dto.request.UserJoinRequest; +import yeonba.be.user.dto.request.UserQueryRequest; import yeonba.be.user.dto.request.UserUpdateDeviceTokenRequest; import yeonba.be.user.dto.response.UserProfileResponse; +import yeonba.be.user.dto.response.UserQueryPageResponse; +import yeonba.be.user.dto.response.UserQueryResponse; import yeonba.be.user.entity.Animal; import yeonba.be.user.entity.Area; import yeonba.be.user.entity.ProfilePhoto; import yeonba.be.user.entity.User; import yeonba.be.user.entity.UserPreference; +import yeonba.be.user.entity.UserRecommendation; import yeonba.be.user.entity.VocalRange; import yeonba.be.user.enums.Gender; import yeonba.be.user.enums.LoginType; @@ -27,6 +35,8 @@ import yeonba.be.user.repository.user.UserCommand; import yeonba.be.user.repository.user.UserQuery; import yeonba.be.user.repository.userpreference.UserPreferenceCommand; +import yeonba.be.user.repository.userrecommendation.UserRecommendationCommand; +import yeonba.be.user.repository.userrecommendation.UserRecommendationQuery; import yeonba.be.user.repository.vocalrange.VocalRangeQuery; import yeonba.be.util.AgeValidator; import yeonba.be.util.S3Service; @@ -35,16 +45,16 @@ @RequiredArgsConstructor public class UserService { - private final int JOIN_REWARD_ARROWS = 30; - private final ProfilePhotoCommand profilePhotoCommand; private final UserCommand userCommand; private final UserPreferenceCommand userPreferenceCommand; + private final UserRecommendationCommand userRecommendationCommand; private final AnimalQuery animalQuery; private final AreaQuery areaQuery; private final ArrowQuery arrowQuery; private final UserQuery userQuery; + private final UserRecommendationQuery userRecommendationQuery; private final VocalRangeQuery vocalRangeQuery; private final S3Service s3Service; @@ -102,6 +112,7 @@ public User saveUser(UserJoinRequest request) { Area area = areaQuery.findByName(request.getActivityArea()); // 사용자 생성 및 저장 + int joinRewardArrows = 30; User user = new User( request.getSocialId(), loginType, @@ -111,7 +122,7 @@ public User saveUser(UserJoinRequest request) { age, request.getHeight(), request.getPhoneNumber(), - JOIN_REWARD_ARROWS, + joinRewardArrows, request.getPhotoSyncRate(), request.getBodyType(), request.getJob(), @@ -158,6 +169,71 @@ public void saveUserPreference(User user, UserJoinRequest request) { userPreferenceCommand.save(userPreference); } + @Transactional(readOnly = true) + public UserQueryPageResponse findUsersByQueryCondition(long userId, UserQueryRequest request) { + + int page = Optional.ofNullable(request.getPage()).orElse(0); + int size = 6; + PageRequest pageRequest = PageRequest.of(page, size); + + // 사용자 존재 여부 검증 + if (!userQuery.validateExistsById(userId)) { + throw new GeneralException(UserException.USER_NOT_FOUND); + } + + String type = request.getType(); + if (StringUtils.equals(type, "FAVORITES")) { + + return userQuery.findFavoritesBy(userId, pageRequest); + } + + if (StringUtils.equals(type, "ARROW_RECEIVERS")) { + + return userQuery.findArrowReceiversBy(userId, pageRequest); + } + + return userQuery.findArrowSendersBy(userId, pageRequest); + } + + @Transactional + public UserQueryPageResponse findRecommendUsers(long userId, LocalDate recommendDay) { + + User user = userQuery.findById(userId); + + // 한 번 추천받았을 경우 다음 시도부턴 화살 소모 + int arrowsForRecommend = 5; + if (userRecommendationQuery.existsRecommendationForUserOnDay(user, recommendDay)) { + user.minusArrow(arrowsForRecommend); + } + + // 추천 사용자 응답 조회, + int numberOfRecommendUsers = 2; + boolean userGender = Gender.from(user.getGenderString()).genderBoolean; + PageRequest pageRequest = PageRequest.of(0, numberOfRecommendUsers); + UserQueryPageResponse response = userQuery + .findRecommendUsers(userId, userGender, pageRequest, recommendDay); + + // 추천 가능 여부 확인(추천 가능한 사용자 2명 이상) + List content = response.getUsers(); + if (content.size() < numberOfRecommendUsers) { + throw new GeneralException(UserException.NO_MORE_USERS_TO_RECOMMEND); + } + + // 추천 사용자 조회 + List userIds = content.stream() + .map(UserQueryResponse::getId) + .toList(); + List recommendUsers = userQuery.findByIds(userIds); + + // 추천 내역 저장 + List userRecommendations = recommendUsers.stream() + .map(recommendUser -> new UserRecommendation(user, recommendUser)) + .toList(); + userRecommendationCommand.saveAll(userRecommendations); + + return response; + } + @Transactional public void updateDeviceToken(long userId, UserUpdateDeviceTokenRequest request) {