diff --git a/src/main/java/sixgaezzang/sidepeek/auth/dto/request/LoginRequest.java b/src/main/java/sixgaezzang/sidepeek/auth/dto/request/LoginRequest.java index b2d48b59..f3a6f4e5 100644 --- a/src/main/java/sixgaezzang/sidepeek/auth/dto/request/LoginRequest.java +++ b/src/main/java/sixgaezzang/sidepeek/auth/dto/request/LoginRequest.java @@ -1,7 +1,7 @@ package sixgaezzang.sidepeek.auth.dto.request; -import static sixgaezzang.sidepeek.auth.exeption.message.AuthErrorMessage.EMAIL_IS_NULL; -import static sixgaezzang.sidepeek.auth.exeption.message.AuthErrorMessage.PASSWORD_IS_NULL; +import static sixgaezzang.sidepeek.auth.exception.message.AuthErrorMessage.EMAIL_IS_NULL; +import static sixgaezzang.sidepeek.auth.exception.message.AuthErrorMessage.PASSWORD_IS_NULL; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/sixgaezzang/sidepeek/auth/dto/request/ReissueTokenRequest.java b/src/main/java/sixgaezzang/sidepeek/auth/dto/request/ReissueTokenRequest.java index 65865990..2affe1dc 100644 --- a/src/main/java/sixgaezzang/sidepeek/auth/dto/request/ReissueTokenRequest.java +++ b/src/main/java/sixgaezzang/sidepeek/auth/dto/request/ReissueTokenRequest.java @@ -1,6 +1,6 @@ package sixgaezzang.sidepeek.auth.dto.request; -import static sixgaezzang.sidepeek.auth.exeption.message.AuthErrorMessage.REFRESH_TOKEN_IS_NULL; +import static sixgaezzang.sidepeek.auth.exception.message.AuthErrorMessage.REFRESH_TOKEN_IS_NULL; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/sixgaezzang/sidepeek/auth/exeption/message/AuthErrorMessage.java b/src/main/java/sixgaezzang/sidepeek/auth/exception/message/AuthErrorMessage.java similarity index 88% rename from src/main/java/sixgaezzang/sidepeek/auth/exeption/message/AuthErrorMessage.java rename to src/main/java/sixgaezzang/sidepeek/auth/exception/message/AuthErrorMessage.java index 943f976b..fea9ad41 100644 --- a/src/main/java/sixgaezzang/sidepeek/auth/exeption/message/AuthErrorMessage.java +++ b/src/main/java/sixgaezzang/sidepeek/auth/exception/message/AuthErrorMessage.java @@ -1,4 +1,4 @@ -package sixgaezzang.sidepeek.auth.exeption.message; +package sixgaezzang.sidepeek.auth.exception.message; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java b/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java index 4aeaf86c..417ca185 100644 --- a/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java +++ b/src/main/java/sixgaezzang/sidepeek/common/doc/ProjectControllerDoc.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import sixgaezzang.sidepeek.common.exception.ErrorResponse; @@ -16,6 +17,7 @@ import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectResponse; @@ -29,7 +31,29 @@ public interface ProjectControllerDoc { @ApiResponse(responseCode = "401", description = "UNAUTHORIZED", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) ResponseEntity save(@Parameter(hidden = true) Long loginId, - SaveProjectRequest request); + SaveProjectRequest request); + + @Operation(summary = "프로젝트 상세 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "NOT_FOUND", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @Parameter(name = "id", description = "조회할 프로젝트 식별자", in = ParameterIn.PATH) + ResponseEntity getById(Long id); + + @Operation(summary = "프로젝트 전체 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true) + }) + ResponseEntity> getByCondition( + @Parameter(hidden = true) Long loginId, + @Valid @ModelAttribute CursorPaginationInfoRequest pageable); + + @Operation(summary = "금주의 인기 프로젝트 조회(배너용)", description = "주간 좋아요 기록을 통해 프로젝트 인기순으로 정렬") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true) + }) + ResponseEntity> getAllPopularThisWeek(); @Operation(summary = "프로젝트 수정", description = "프로젝트 작성자와 멤버만 수정이 가능합니다.") @ApiResponses({ @@ -41,7 +65,7 @@ ResponseEntity save(@Parameter(hidden = true) Long loginId, }) @Parameter(name = "id", description = "수정할 프로젝트 식별자", in = ParameterIn.PATH) ResponseEntity update(@Parameter(hidden = true) Long loginId, Long projectId, - UpdateProjectRequest request); + UpdateProjectRequest request); @Operation(summary = "프로젝트 삭제", description = "프로젝트 작성자만 삭제가 가능합니다.") @ApiResponses({ @@ -53,19 +77,4 @@ ResponseEntity update(@Parameter(hidden = true) Long loginId, L @Parameter(name = "id", description = "삭제할 프로젝트 식별자", in = ParameterIn.PATH) ResponseEntity delete(@Parameter(hidden = true) Long loginId, Long projectId); - @Operation(summary = "프로젝트 상세 조회") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "NOT_FOUND", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - @Parameter(name = "id", description = "조회할 프로젝트 식별자", in = ParameterIn.PATH) - ResponseEntity getById(Long id); - - @Operation(summary = "프로젝트 전체 조회") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "OK", useReturnTypeSchema = true) - }) - ResponseEntity> getByCondition( - @Parameter(hidden = true) Long loginId, - @Valid @ModelAttribute CursorPaginationInfoRequest pageable); } diff --git a/src/main/java/sixgaezzang/sidepeek/common/exception/GlobalExceptionHandler.java b/src/main/java/sixgaezzang/sidepeek/common/exception/GlobalExceptionHandler.java index f5e7ba23..f98d74ce 100644 --- a/src/main/java/sixgaezzang/sidepeek/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/sixgaezzang/sidepeek/common/exception/GlobalExceptionHandler.java @@ -88,16 +88,6 @@ public ResponseEntity handleInvalidAuthenticationException( .body(errorResponse); } - @ExceptionHandler(InvalidAuthorityException.class) - public ResponseEntity handleInvalidAuthorityException( - InvalidAuthorityException e) { - ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.FORBIDDEN, e.getMessage()); - log.warn(e.getMessage(), e.fillInStackTrace()); - - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(errorResponse); - } - @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException( HttpMessageNotReadableException e) { diff --git a/src/main/java/sixgaezzang/sidepeek/common/exception/InvalidAuthorityException.java b/src/main/java/sixgaezzang/sidepeek/common/exception/InvalidAuthorityException.java deleted file mode 100644 index 48429d38..00000000 --- a/src/main/java/sixgaezzang/sidepeek/common/exception/InvalidAuthorityException.java +++ /dev/null @@ -1,9 +0,0 @@ -package sixgaezzang.sidepeek.common.exception; - -public class InvalidAuthorityException extends RuntimeException { - - public InvalidAuthorityException(String message) { - super(message); - } - -} diff --git a/src/main/java/sixgaezzang/sidepeek/common/util/component/DateTimeProvider.java b/src/main/java/sixgaezzang/sidepeek/common/util/component/DateTimeProvider.java new file mode 100644 index 00000000..cb842843 --- /dev/null +++ b/src/main/java/sixgaezzang/sidepeek/common/util/component/DateTimeProvider.java @@ -0,0 +1,28 @@ +package sixgaezzang.sidepeek.common.util.component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.springframework.stereotype.Component; + +@Component +public class DateTimeProvider { + + /** + * 현재 날짜를 반환하는 메서드 + * + * @return {@code LocalDate} + */ + public LocalDate getCurrentDate() { + return LocalDate.now(); + } + + /** + * 현재 날짜, 시간를 반환하는 메서드 + * + * @return {@code LocalDateTime} + */ + public LocalDateTime getCurrentDateTime() { + return LocalDateTime.now(); + } + +} diff --git a/src/main/java/sixgaezzang/sidepeek/common/util/validation/ValidationUtils.java b/src/main/java/sixgaezzang/sidepeek/common/util/validation/ValidationUtils.java index a548e517..54fa186a 100644 --- a/src/main/java/sixgaezzang/sidepeek/common/util/validation/ValidationUtils.java +++ b/src/main/java/sixgaezzang/sidepeek/common/util/validation/ValidationUtils.java @@ -25,9 +25,9 @@ import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.security.access.AccessDeniedException; import org.springframework.util.Assert; import sixgaezzang.sidepeek.common.exception.InvalidAuthenticationException; -import sixgaezzang.sidepeek.common.exception.InvalidAuthorityException; @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ValidationUtils { @@ -134,7 +134,7 @@ public static boolean isNullOrEmpty(Collection input) { public static void validateLoginIdEqualsOwnerId(Long loginId, Long ownerId) { validateOwnerId(ownerId); if (!loginId.equals(ownerId)) { - throw new InvalidAuthorityException(OWNER_ID_NOT_EQUALS_LOGIN_ID); + throw new AccessDeniedException(OWNER_ID_NOT_EQUALS_LOGIN_ID); } } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java b/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java index 8e1f0b6e..7e15f0d7 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/controller/ProjectController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import java.net.URI; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -20,6 +21,7 @@ import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectResponse; import sixgaezzang.sidepeek.projects.service.ProjectService; @@ -46,6 +48,35 @@ public ResponseEntity save( .body(response); } + @Override + @GetMapping("/{id}") + public ResponseEntity getById( + @PathVariable Long id + ) { + ProjectResponse response = projectService.findById(id); + + return ResponseEntity.ok(response); + } + + @Override + @GetMapping + public ResponseEntity> getByCondition( + @Login Long loginId, + @Valid @ModelAttribute CursorPaginationInfoRequest pageable + ) { + CursorPaginationResponse responses = projectService.findByCondition( + loginId, pageable); + return ResponseEntity.ok().body(responses); + } + + @Override + @GetMapping("/weekly") + public ResponseEntity> getAllPopularThisWeek() { + List responses = projectService.findAllPopularLastWeek(); + + return ResponseEntity.ok(responses); + } + @Override @PutMapping("/{id}") public ResponseEntity update( @@ -69,25 +100,4 @@ public ResponseEntity delete( return ResponseEntity.noContent() .build(); } - - @Override - @GetMapping("/{id}") - public ResponseEntity getById( - @PathVariable Long id - ) { - ProjectResponse response = projectService.findById(id); - - return ResponseEntity.ok(response); - } - - @Override - @GetMapping - public ResponseEntity> getByCondition( - @Login Long loginId, - @Valid @ModelAttribute CursorPaginationInfoRequest pageable - ) { - CursorPaginationResponse responses = projectService.findByCondition( - loginId, pageable); - return ResponseEntity.ok().body(responses); - } } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/domain/Project.java b/src/main/java/sixgaezzang/sidepeek/projects/domain/Project.java index ced95698..29089c38 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/domain/Project.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/domain/Project.java @@ -120,9 +120,9 @@ public void increaseViewCount() { this.viewCount++; } - public void softDelete() { + public void softDelete(LocalDateTime now) { if (Objects.isNull(this.deletedAt)) { - this.deletedAt = LocalDateTime.now(); + this.deletedAt = now; return; } throw new IllegalStateException(PROJECT_ALREADY_DELETED); diff --git a/src/main/java/sixgaezzang/sidepeek/projects/dto/response/ProjectBannerResponse.java b/src/main/java/sixgaezzang/sidepeek/projects/dto/response/ProjectBannerResponse.java new file mode 100644 index 00000000..3a991209 --- /dev/null +++ b/src/main/java/sixgaezzang/sidepeek/projects/dto/response/ProjectBannerResponse.java @@ -0,0 +1,29 @@ +package sixgaezzang.sidepeek.projects.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import sixgaezzang.sidepeek.projects.domain.Project; + +@Schema(description = "배너용 프로젝트 응답") +@Builder +public record ProjectBannerResponse( + @Schema(description = "프로젝트 식별자", example = "1") + Long id, + @Schema(description = "프로젝트 제목", example = "사이드픽👀") + String name, + @Schema(description = "프로젝트 부제목", example = "요즘 사이드 플젝 뭐함? 사이드픽 \uD83D\uDC40") + String subName, + @Schema(description = "프로젝트 썸네일 이미지 URL", example = "https://sidepeek.image/imageeUrl") + String thumbnailUrl +) { + + public static ProjectBannerResponse from(Project project) { + return ProjectBannerResponse.builder() + .id(project.getId()) + .name(project.getName()) + .subName(project.getSubName()) + .thumbnailUrl(project.getThumbnailUrl()) + .build(); + } + +} diff --git a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java index eeef07fe..0af0e48f 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustom.java @@ -1,10 +1,12 @@ package sixgaezzang.sidepeek.projects.repository.project; +import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.users.domain.User; @@ -14,12 +16,14 @@ CursorPaginationResponse findByCondition( List likedProjectIds, CursorPaginationInfoRequest pageable); + List findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate, int count); + Page findAllByUserJoined(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); Page findAllByUserLiked(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); Page findAllByUserCommented(List likedProjectIds, User user, - Pageable pageable); + Pageable pageable); } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java index 4eee7e27..7f9d1180 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/repository/project/ProjectRepositoryCustomImpl.java @@ -7,11 +7,13 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateTemplate; import com.querydsl.core.types.dsl.EntityPathBase; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberTemplate; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; +import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -22,6 +24,7 @@ import sixgaezzang.sidepeek.projects.dto.request.CursorPaginationInfoRequest; import sixgaezzang.sidepeek.projects.dto.request.SortType; import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.users.domain.User; @@ -65,7 +68,7 @@ public CursorPaginationResponse findByCondition( @Override public Page findAllByUserJoined(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression memberCondition = member.user.eq(user); return findPageByCondition(member, member.project, memberCondition, pageable, likedProjectIds); @@ -73,22 +76,22 @@ public Page findAllByUserJoined(List likedProjectIds, @Override public Page findAllByUserLiked(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression likeCondition = like.user.eq(user); return findPageByCondition(like, like.project, likeCondition, pageable, likedProjectIds); } @Override public Page findAllByUserCommented(List likedProjectIds, User user, - Pageable pageable) { + Pageable pageable) { BooleanExpression commentCondition = comment.user.eq(user); return findPageByCondition(comment, comment.project, commentCondition, pageable, likedProjectIds); } private Page findPageByCondition(EntityPathBase from, - QProject join, BooleanExpression condition, Pageable pageable, - List likedProjectIds) { + QProject join, BooleanExpression condition, Pageable pageable, + List likedProjectIds) { List projects = queryFactory .select(project) .from(from) @@ -116,13 +119,33 @@ private Long getCount(EntityPathBase from, BooleanExpression condition, QProj } private List toProjectListResponseList(List likedProjectIds, - List projects) { + List projects) { return projects.stream() .map(project -> ProjectListResponse.from(project, likedProjectIds.contains(project.getId()))) .toList(); } + @Override + public List findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate, int count) { + DateTemplate createdAt = Expressions.dateTemplate( + LocalDate.class, "DATE_FORMAT({0}, {1})", like.createdAt, "%Y-%m-%d"); + + List projects = queryFactory + .select(project) + .from(like) + .join(like.project, project) + .where(createdAt.between(startDate, endDate)) + .groupBy(project) + .orderBy(like.count().desc()) + .limit(count) + .fetch(); + + return projects.stream() + .map(ProjectBannerResponse::from) + .toList(); + } + private long getTotalElementsByCondition(BooleanExpression deployCondition) { NumberTemplate countTemplate = Expressions.numberTemplate(Long.class, "COUNT({0})", project.id); diff --git a/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java b/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java index 8781b8c5..8aea89b9 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/service/ProjectService.java @@ -5,10 +5,14 @@ import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.ONLY_OWNER_AND_FELLOW_MEMBER_CAN_UPDATE; import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.PROJECT_NOT_EXISTING; import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.USER_PROJECT_SEARCH_TYPE_IS_INVALID; +import static sixgaezzang.sidepeek.projects.util.DateUtils.getEndDayOfLastWeek; +import static sixgaezzang.sidepeek.projects.util.DateUtils.getStartDayOfLastWeek; +import static sixgaezzang.sidepeek.projects.util.ProjectConstant.BANNER_PROJECT_COUNT; import static sixgaezzang.sidepeek.users.exception.message.UserErrorMessage.USER_NOT_EXISTING; import static sixgaezzang.sidepeek.users.util.validation.UserValidator.validateLoginIdEqualsUserId; import jakarta.persistence.EntityNotFoundException; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -21,6 +25,7 @@ import sixgaezzang.sidepeek.common.dto.request.SaveTechStackRequest; import sixgaezzang.sidepeek.common.dto.response.Page; import sixgaezzang.sidepeek.common.exception.InvalidAuthenticationException; +import sixgaezzang.sidepeek.common.util.component.DateTimeProvider; import sixgaezzang.sidepeek.like.repository.LikeRepository; import sixgaezzang.sidepeek.projects.domain.Project; import sixgaezzang.sidepeek.projects.domain.UserProjectSearchType; @@ -32,6 +37,7 @@ import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse; import sixgaezzang.sidepeek.projects.dto.response.MemberSummary; import sixgaezzang.sidepeek.projects.dto.response.OverviewImageSummary; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectSkillSummary; @@ -44,6 +50,7 @@ @Transactional(readOnly = true) public class ProjectService { + private final DateTimeProvider dateTimeProvider; private final ProjectRepository projectRepository; private final UserRepository userRepository; private final ProjectSkillService projectSkillService; @@ -104,8 +111,16 @@ public ProjectResponse findById(Long id) { return ProjectResponse.from(project, overviewImages, techStacks, members, comments); } + public List findAllPopularLastWeek() { + LocalDate today = dateTimeProvider.getCurrentDate(); + LocalDate startDate = getStartDayOfLastWeek(today); + LocalDate endDate = getEndDayOfLastWeek(today); + + return projectRepository.findAllPopularOfPeriod(startDate, endDate, BANNER_PROJECT_COUNT); + } + public Page findByUser(Long userId, Long loginId, - UserProjectSearchType type, Pageable pageable) { + UserProjectSearchType type, Pageable pageable) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException(USER_NOT_EXISTING)); @@ -145,7 +160,7 @@ public void delete(Long loginId, Long projectId) { Project project = getById(projectId); validateLoginIdEqualsOwnerId(loginId, project.getOwnerId()); - project.softDelete(); + project.softDelete(dateTimeProvider.getCurrentDateTime()); } private void validateLoginUserIncludeMembers(Long loginId, Project project) { @@ -161,19 +176,19 @@ private List getLikedProjectIds(Long userId) { } private Page findJoinedProjectsByUser(User user, - List likedProjectIds, - Pageable pageable) { + List likedProjectIds, + Pageable pageable) { return Page.from(projectRepository.findAllByUserJoined(likedProjectIds, user, pageable)); } private Page findLikedProjectsByUser(User user, List likedProjectIds, - Pageable pageable) { + Pageable pageable) { return Page.from(projectRepository.findAllByUserLiked(likedProjectIds, user, pageable)); } private Page findAllByUserCommentedByUser(User user, - List likedProjectIds, - Pageable pageable) { + List likedProjectIds, + Pageable pageable) { return Page.from(projectRepository.findAllByUserCommented(likedProjectIds, user, pageable)); } diff --git a/src/main/java/sixgaezzang/sidepeek/projects/util/DateUtils.java b/src/main/java/sixgaezzang/sidepeek/projects/util/DateUtils.java new file mode 100644 index 00000000..b88757e0 --- /dev/null +++ b/src/main/java/sixgaezzang/sidepeek/projects/util/DateUtils.java @@ -0,0 +1,48 @@ +package sixgaezzang.sidepeek.projects.util; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DateUtils { + + /** + * {@code localDate} 기준으로 지난 주 월요일을 찾는 메서드. + *

+ * 기준 날짜의 요일이 [화, 수, 목, 금]이라면 {@link DateUtils#getEndDayOfLastWeek}를 + * 두 번 호출하여 지난 주 월요일 날짜를 얻는다. + * @param localDate 기준이 되는 날짜 + * @return 지난 주 월요일 날짜를 {@code LocalDate}으로 반환한다. + */ + public static LocalDate getStartDayOfLastWeek(LocalDate localDate) { + if (!localDate.getDayOfWeek().equals(DayOfWeek.MONDAY)) { + localDate = getNearestPastDayOfWeek(localDate, DayOfWeek.MONDAY); + } + + return getNearestPastDayOfWeek(localDate, DayOfWeek.MONDAY); + } + + /** + * {@code localDate} 기준으로 지난 주 일요일을 찾는 메서드. + * @param localDate 기준이 되는 날짜 + * @return 지난 주 일요일 날짜를 {@code LocalDate}으로 반환한다. + */ + public static LocalDate getEndDayOfLastWeek(LocalDate localDate) { + return getNearestPastDayOfWeek(localDate, DayOfWeek.SUNDAY); + } + + /** + * {@code localDate} 기준으로 {@code localDate}에 해당하는 요일인 + * 가장 가까운 과거 날짜를 찾는 메서드 + * @param localDate 기준이 되는 날짜 + * @param dayOfWeek 찾고자 하는 가까운 과거 날짜의 요일 + * @return 가장 가까운 과거 요일 날짜를 {@code LocalDate}로 반환한다. + */ + public static LocalDate getNearestPastDayOfWeek(LocalDate localDate, DayOfWeek dayOfWeek) { + return localDate.with(TemporalAdjusters.previous(dayOfWeek)); + } + +} diff --git a/src/main/java/sixgaezzang/sidepeek/projects/util/ProjectConstant.java b/src/main/java/sixgaezzang/sidepeek/projects/util/ProjectConstant.java index 6dfa1eef..2926256e 100644 --- a/src/main/java/sixgaezzang/sidepeek/projects/util/ProjectConstant.java +++ b/src/main/java/sixgaezzang/sidepeek/projects/util/ProjectConstant.java @@ -9,6 +9,7 @@ public final class ProjectConstant { public static final int MAX_PROJECT_NAME_LENGTH = 50; public static final int MAX_OVERVIEW_LENGTH = 300; public static final String YEAR_MONTH_PATTERN = "yyyy-MM"; + public static final int BANNER_PROJECT_COUNT = 5; // Member public static final int MAX_MEMBER_COUNT = 10; diff --git a/src/main/java/sixgaezzang/sidepeek/users/domain/User.java b/src/main/java/sixgaezzang/sidepeek/users/domain/User.java index f4e3fc2b..8942d0e6 100644 --- a/src/main/java/sixgaezzang/sidepeek/users/domain/User.java +++ b/src/main/java/sixgaezzang/sidepeek/users/domain/User.java @@ -130,9 +130,9 @@ public void updatePassword(String newPassword, PasswordEncoder passwordEncoder) this.password = new Password(newPassword, passwordEncoder); } - public void softDelete() { // TODO: 회원탈퇴할 때 언젠가는 쓰일 것 같아서 구현 + public void softDelete(LocalDateTime now) { // TODO: 회원탈퇴할 때 언젠가는 쓰일 것 같아서 구현 if (Objects.isNull(this.deletedAt)) { - this.deletedAt = LocalDateTime.now(); + this.deletedAt = now; return; } throw new IllegalStateException(USER_ALREADY_DELETED); diff --git a/src/test/java/sixgaezzang/sidepeek/comments/service/CommentServiceTest.java b/src/test/java/sixgaezzang/sidepeek/comments/service/CommentServiceTest.java index 21170578..350b428e 100644 --- a/src/test/java/sixgaezzang/sidepeek/comments/service/CommentServiceTest.java +++ b/src/test/java/sixgaezzang/sidepeek/comments/service/CommentServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import sixgaezzang.sidepeek.comments.domain.Comment; @@ -32,7 +33,6 @@ import sixgaezzang.sidepeek.comments.dto.request.UpdateCommentRequest; import sixgaezzang.sidepeek.comments.repository.CommentRepository; import sixgaezzang.sidepeek.common.exception.InvalidAuthenticationException; -import sixgaezzang.sidepeek.common.exception.InvalidAuthorityException; import sixgaezzang.sidepeek.projects.domain.Project; import sixgaezzang.sidepeek.projects.repository.project.ProjectRepository; import sixgaezzang.sidepeek.projects.service.ProjectService; @@ -238,7 +238,7 @@ class 댓글_생성_테스트 { request); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(save) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(save) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } @@ -255,7 +255,7 @@ class 댓글_생성_테스트 { request); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(save) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(save) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } @@ -398,7 +398,7 @@ class 댓글_수정_테스트 { newUser.getId(), comment.getId(), request); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(update) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(update) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } @@ -514,7 +514,7 @@ void cleanup() { newUser.getId(), comment.getId()); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(delete) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(delete) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } diff --git a/src/test/java/sixgaezzang/sidepeek/projects/service/ProjectServiceTest.java b/src/test/java/sixgaezzang/sidepeek/projects/service/ProjectServiceTest.java index ac6b1734..7d95469f 100644 --- a/src/test/java/sixgaezzang/sidepeek/projects/service/ProjectServiceTest.java +++ b/src/test/java/sixgaezzang/sidepeek/projects/service/ProjectServiceTest.java @@ -2,12 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; import static sixgaezzang.sidepeek.common.exception.message.CommonErrorMessage.LOGIN_IS_REQUIRED; import static sixgaezzang.sidepeek.common.exception.message.CommonErrorMessage.OWNER_ID_NOT_EQUALS_LOGIN_ID; import static sixgaezzang.sidepeek.common.util.CommonConstant.MAX_TECH_STACK_COUNT; import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.ONLY_OWNER_AND_FELLOW_MEMBER_CAN_UPDATE; import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.OWNER_ID_IS_NULL; import static sixgaezzang.sidepeek.projects.exception.message.ProjectErrorMessage.PROJECT_NOT_EXISTING; +import static sixgaezzang.sidepeek.projects.util.ProjectConstant.BANNER_PROJECT_COUNT; import static sixgaezzang.sidepeek.projects.util.ProjectConstant.MAX_MEMBER_COUNT; import static sixgaezzang.sidepeek.users.exception.message.UserErrorMessage.USER_ID_NOT_EQUALS_LOGIN_ID; import static sixgaezzang.sidepeek.users.exception.message.UserErrorMessage.USER_NOT_EXISTING; @@ -17,6 +19,7 @@ import static sixgaezzang.sidepeek.util.FakeDtoProvider.createSaveTechStackRequests; import static sixgaezzang.sidepeek.util.FakeDtoProvider.createUpdateProjectRequestOnlyRequired; import static sixgaezzang.sidepeek.util.FakeEntityProvider.createComment; +import static sixgaezzang.sidepeek.util.FakeEntityProvider.createLike; import static sixgaezzang.sidepeek.util.FakeEntityProvider.createProject; import static sixgaezzang.sidepeek.util.FakeEntityProvider.createSkill; import static sixgaezzang.sidepeek.util.FakeEntityProvider.createUser; @@ -27,11 +30,14 @@ import static sixgaezzang.sidepeek.util.FakeValueProvider.createOverview; import static sixgaezzang.sidepeek.util.FakeValueProvider.createProjectName; import static sixgaezzang.sidepeek.util.FakeValueProvider.createRole; -import static sixgaezzang.sidepeek.util.FakeValueProvider.createUrl; import static sixgaezzang.sidepeek.util.FakeValueProvider.createUserProjectSearchType; import jakarta.persistence.EntityNotFoundException; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -39,14 +45,16 @@ import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.annotation.Transactional; import sixgaezzang.sidepeek.comments.domain.Comment; import sixgaezzang.sidepeek.comments.dto.response.CommentResponse; @@ -54,7 +62,7 @@ import sixgaezzang.sidepeek.common.dto.request.SaveTechStackRequest; import sixgaezzang.sidepeek.common.dto.response.Page; import sixgaezzang.sidepeek.common.exception.InvalidAuthenticationException; -import sixgaezzang.sidepeek.common.exception.InvalidAuthorityException; +import sixgaezzang.sidepeek.common.util.component.DateTimeProvider; import sixgaezzang.sidepeek.like.domain.Like; import sixgaezzang.sidepeek.like.repository.LikeRepository; import sixgaezzang.sidepeek.projects.domain.Project; @@ -63,6 +71,7 @@ import sixgaezzang.sidepeek.projects.dto.request.SaveMemberRequest; import sixgaezzang.sidepeek.projects.dto.request.SaveProjectRequest; import sixgaezzang.sidepeek.projects.dto.request.UpdateProjectRequest; +import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse; import sixgaezzang.sidepeek.projects.dto.response.ProjectResponse; import sixgaezzang.sidepeek.projects.repository.FileRepository; @@ -76,7 +85,7 @@ @SpringBootTest @Transactional -@DisplayNameGeneration(ReplaceUnderscores.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ProjectServiceTest { static final Faker faker = new Faker(); @@ -90,6 +99,9 @@ class ProjectServiceTest { static String GITHUB_URL = createGithubUrl(); static String DESCRIPTION = createLongText(); + @MockBean + DateTimeProvider dateTimeProvider; + @Autowired ProjectService projectService; @@ -141,6 +153,11 @@ private Comment createAndSaveComment(User user, Project project, Comment comment return commentRepository.save(newComment); } + private Like createAndSaveLike(Project project, User user) { + Like newLike = createLike(user, project); + return likeRepository.save(newLike); + } + @BeforeEach void setup() { members = new ArrayList<>(); @@ -153,7 +170,6 @@ void setup() { users.add(createUser()); } userRepository.saveAll(users) - .stream() .forEach(user -> { fellowMemberIds.add(user.getId()); members.add(createFellowSaveMemberRequest(user.getId())); @@ -167,9 +183,10 @@ void setup() { for (int i = 1; i <= SKILL_COUNT; i++) { skills.add(createSkill()); } + List createdSkillIds = skillRepository.saveAll(skills) .stream() - .map(skill -> skill.getId()) + .map(Skill::getId) .toList(); techStacks = createSaveTechStackRequests(createdSkillIds); @@ -208,6 +225,77 @@ class 프로젝트_상세_조회_테스트 { } } + @Nested + class 지난_주_인기_프로젝트_조회_테스트 { + + LocalDate nextSunday; + + @BeforeEach + void setUp() { + LocalDate today = LocalDate.now(); + if (!today.getDayOfWeek().equals(DayOfWeek.SUNDAY)) { + today = today.with(TemporalAdjusters.next(DayOfWeek.SUNDAY)); + } + + nextSunday = today.with(TemporalAdjusters.next(DayOfWeek.SUNDAY)); + } + + @Test + void 최대_5개로_지난_주_인기_프로젝트_조회를_성공한다() { + // given + int overBannerProjectCount = BANNER_PROJECT_COUNT * 2; + for (int i = 0; i < overBannerProjectCount; i++) { + Project project = createAndSaveProject(user); + User newUser = createAndSaveUser(); + createAndSaveLike(project, newUser); + } + + // when + given(dateTimeProvider.getCurrentDate()).willReturn(nextSunday); + List responses = projectService.findAllPopularLastWeek(); + + // then + assertThat(responses).hasSize(BANNER_PROJECT_COUNT); + } + + @Test + void 좋아요를_많이_받은_순으로_지난_주_인기_프로젝트_조회를_성공한다() { + // TODO: project likeCount 반영 후 테스트 예정 + // // given + // List projects = new ArrayList<>(); + // for (int i = 0; i < BANNER_PROJECT_COUNT; i++) { + // Project project = createAndSaveProject(user); + // projects.add(project); + // for (int j = 0; j < i + 1; j++) { + // User newUser = createAndSaveUser(); + // createAndSaveLike(project, newUser); + // } + // } + // projects.sort(Comparator.comparing(Project::getLikeCount)); + // List expectResponses = projects.stream() + // .map(ProjectBannerResponse::from) + // .toList(); + // + // // when + // given(dateTimeProvider.getCurrentDate()).willReturn(nextSunday); + // List responses = projectService.findAllPopularLastWeek(); + // + // // then + // assertThat(responses).isEqualTo(expectResponses); + } + + @Test + void 지난_주에_좋아요_기록이_없어_빈_배열로_지난_주_인기_프로젝트_조회를_성공한다() { + // given, when + given(dateTimeProvider.getCurrentDate()).willReturn(nextSunday); + List responses = projectService.findAllPopularLastWeek(); + + // then + assertThat(responses).isEmpty(); + } + + } + @Nested class 회원_관련_프로젝트_전체_조회_테스트 { @@ -449,7 +537,7 @@ class 프로젝트_저장_테스트 { ThrowingCallable saveProject = () -> projectService.save(user.getId(), request); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(saveProject) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(saveProject) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } @@ -548,7 +636,7 @@ class 프로젝트_수정_테스트 { // when String newName = createProjectName(); String newOverview = createOverview(); - String newGithubUrl = createUrl(); + String newGithubUrl = createGithubUrl(); String newDescription = createLongText(); UpdateProjectRequest newRequest = createUpdateProjectRequestOnlyRequired( newName, newOverview, newGithubUrl, newDescription, techStacks, @@ -570,7 +658,7 @@ class 프로젝트_수정_테스트 { // when String newName = createProjectName(); String newOverview = createOverview(); - String newGithubUrl = createUrl(); + String newGithubUrl = createGithubUrl(); String newDescription = createLongText(); UpdateProjectRequest newRequest = createUpdateProjectRequestOnlyRequired( newName, newOverview, newGithubUrl, newDescription, techStacks, members @@ -597,7 +685,7 @@ class 프로젝트_수정_테스트 { // when String newName = createProjectName(); String newOverview = createOverview(); - String newGithubUrl = createUrl(); + String newGithubUrl = createGithubUrl(); String newDescription = createLongText(); UpdateProjectRequest newRequest = createUpdateProjectRequestOnlyRequired( newName, newOverview, newGithubUrl, newDescription, techStacks, @@ -619,6 +707,7 @@ class 프로젝트_삭제_테스트 { @Test void 프로젝트_소프트_삭제에_성공한다() { // given + given(dateTimeProvider.getCurrentDateTime()).willReturn(LocalDateTime.now()); ProjectResponse project = getNewSavedProject(user.getId()); // when @@ -636,6 +725,7 @@ class 프로젝트_삭제_테스트 { @Test void 로그인하지_않은_사용자라서__프로젝트_삭제에_실패한다() { // given + given(dateTimeProvider.getCurrentDateTime()).willReturn(LocalDateTime.now()); ProjectResponse project = getNewSavedProject(user.getId()); // when @@ -649,6 +739,7 @@ class 프로젝트_삭제_테스트 { @Test void 존재하지_않는_프로젝트_삭제에_실패한다() { // given + given(dateTimeProvider.getCurrentDateTime()).willReturn(LocalDateTime.now()); ProjectResponse project = getNewSavedProject(user.getId()); // when @@ -662,6 +753,7 @@ class 프로젝트_삭제_테스트 { @Test void 프로젝트_작성자가_아니라서_프로젝트_삭제에_실패한다() { // given + given(dateTimeProvider.getCurrentDateTime()).willReturn(LocalDateTime.now()); ProjectResponse project = getNewSavedProject(user.getId()); User newUser = createAndSaveUser(); @@ -670,7 +762,7 @@ class 프로젝트_삭제_테스트 { ThrowingCallable delete = () -> projectService.delete(newUser.getId(), project.id()); // then - assertThatExceptionOfType(InvalidAuthorityException.class).isThrownBy(delete) + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(delete) .withMessage(OWNER_ID_NOT_EQUALS_LOGIN_ID); } diff --git a/src/test/java/sixgaezzang/sidepeek/util/FakeEntityProvider.java b/src/test/java/sixgaezzang/sidepeek/util/FakeEntityProvider.java index 1d570b9b..9ad1b19c 100644 --- a/src/test/java/sixgaezzang/sidepeek/util/FakeEntityProvider.java +++ b/src/test/java/sixgaezzang/sidepeek/util/FakeEntityProvider.java @@ -75,7 +75,7 @@ public static Skill createSkill() { // Comment public static Comment createComment(User user, Project project, Comment parent, - boolean isAnonymous) { + boolean isAnonymous) { return Comment.builder() .user(user) .project(Objects.nonNull(project) ? project : parent.getProject()) @@ -112,4 +112,5 @@ public static Like createLike(User user, Project project) { .project(project) .build(); } + }