diff --git a/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java index 7a7f03e..03970ad 100644 --- a/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java @@ -40,7 +40,7 @@ public class CreateStudyService { public void createStudy(CreateStudyRequest request, Long leaderId, MultipartFile imageFile) { long studyId = idGenerator.nextId(); Study study = Study.of(studyId, leaderId, request.name(), request.maxMembers(), - Fee.of(request.hasFee(), request.amount()), request.description()); + Fee.of(request.hasFee(), request.amount()), null, request.description(), request.isOnline()); StudyStats studyStats = StudyStats.of(studyId); StudyMember studyMember = StudyMember.create(idGenerator.nextId(), studyId, leaderId); diff --git a/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java index 97c37fc..7094ddb 100644 --- a/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java +++ b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java @@ -1,15 +1,23 @@ package kr.spot.study.application.mapper; +import java.util.Collections; import java.util.List; +import java.util.Set; import kr.spot.study.domain.Study; import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse.StudyOverview; public class StudyDTOMapper { - public static GetStudyOverviewResponse toDTO(List studies, boolean hasNext, + public static GetStudyOverviewResponse toDTO( + List studies, + Set likedStudyIds, + boolean hasNext, Long nextCursor, - Long totalElements) { + Long totalElements + ) { + Set safelikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet(); + List list = studies.stream().map( study -> StudyOverview.of( study.getId(), @@ -18,7 +26,7 @@ public static GetStudyOverviewResponse toDTO(List studies, boolean hasNex study.getMaxMembers(), study.getCurrentMembers(), 0, - false, + safelikedIds.contains(study.getId()), 0, study.getImageUrl() ) @@ -26,4 +34,13 @@ public static GetStudyOverviewResponse toDTO(List studies, boolean hasNex return GetStudyOverviewResponse.of(list, hasNext, nextCursor, totalElements); } + + public static GetStudyOverviewResponse toDTO( + List studies, + boolean hasNext, + Long nextCursor, + Long totalElements + ) { + return toDTO(studies, Collections.emptySet(), hasNext, nextCursor, totalElements); + } } diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index 358c796..1be4610 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -1,6 +1,9 @@ package kr.spot.study.application.query; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Set; import kr.spot.ports.GetPreferredCategoryPort; import kr.spot.ports.GetPreferredRegionPort; import kr.spot.study.application.mapper.StudyDTOMapper; @@ -10,6 +13,7 @@ import kr.spot.study.domain.enums.RecruitingStatus; import kr.spot.study.domain.enums.SortBy; import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; import kr.spot.study.infrastructure.jpa.querydsl.StudyQueryRepository; import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import lombok.RequiredArgsConstructor; @@ -26,9 +30,10 @@ public class GetMyStudyInfoService { private final GetPreferredRegionPort getPreferredRegionPort; private final GetPreferredCategoryPort getPreferredCategoryPort; private final StudyQueryRepository studyQueryRepository; + private final StudyLikeRepository studyLikeRepository; public GetStudyOverviewResponse getMyStudyOverview( - Long viewerId, + long viewerId, StudyMemberStatus status, Long cursor, int size @@ -45,83 +50,196 @@ public GetStudyOverviewResponse getMyStudyOverview( viewerId, status ); - return toCursorPage(rows, pageSize, totalElements); + return toCursorPage(rows, viewerId, pageSize, totalElements); } public GetStudyOverviewResponse getMyPreferredRegionStudies( - Long viewerId, + long viewerId, RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, SortBy sortBy, Long cursor, - Integer size, + int size, List regionCodes ) { final int pageSize = Math.min(size, MAX_PAGE_SIZE); List preferredRegionCodes = getPreferredRegionPort.get(viewerId); + List filteredRegionCodes = filterPreferredRegionCodes(regionCodes, preferredRegionCodes); List rows = studyQueryRepository.findMyPreferredRegionStudies( recruitingStatus, feeCategory, categories, + isOnline, sortBy, cursor, pageSize + 1, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) + filteredRegionCodes ); long totalElements = studyQueryRepository.countMyPreferredRegionStudies( recruitingStatus, feeCategory, categories, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) + isOnline, + filteredRegionCodes ); - return toCursorPage(rows, pageSize, totalElements); + return toCursorPage(rows, viewerId, pageSize, totalElements); } public GetStudyOverviewResponse getMyPreferredCategoryStudies( - Long viewerId, + long viewerId, Category category, RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, SortBy sortBy, Long cursor, - Integer size + int size ) { final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List preferredRegionCodes = getCategories(viewerId, category); + List preferredCategories = getCategories(viewerId, category); List rows = studyQueryRepository.findMyPreferredCategoryStudies( recruitingStatus, feeCategory, + isOnline, sortBy, cursor, pageSize + 1, - preferredRegionCodes + preferredCategories ); long totalElements = studyQueryRepository.countMyPreferredCategoryStudies( recruitingStatus, feeCategory, - preferredRegionCodes + isOnline, + preferredCategories + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getRecruitingStudies( + long viewerId, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findRecruitingStudies( + feeCategory, + categories, + isOnline, + sortBy, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countRecruitingStudies( + feeCategory, + categories, + isOnline + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getStudiesByCategory( + long viewerId, + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline, + sortBy, + cursor, + pageSize + 1 ); - return toCursorPage(rows, pageSize, totalElements); + + long totalElements = studyQueryRepository.countStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); } - public GetStudyOverviewResponse getLikedStudies(long memberId, Long cursor, int size) { + public GetStudyOverviewResponse getLikedStudies(long viewerId, Long cursor, int size) { final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List rows = studyQueryRepository.findLikedStudies(memberId, cursor, pageSize + 1); - long totalElements = studyQueryRepository.countLikedStudies(memberId); - return toCursorPage(rows, pageSize, totalElements); + List rows = studyQueryRepository.findLikedStudies(viewerId, cursor, pageSize + 1); + long totalElements = studyQueryRepository.countLikedStudies(viewerId); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getRecommendedStudies(long viewerId) { + final int recommendCount = 3; + + List preferredCategories = getPreferredCategories(viewerId); + List result = pickRandomStudiesFromPreferred(preferredCategories, recommendCount); + fillWithPopularStudies(result, recommendCount); + + Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); + return StudyDTOMapper.toDTO(result, likedStudyIds, false, null, (long) result.size()); + } + + private List getPreferredCategories(long memberId) { + return getPreferredCategoryPort.get(memberId) + .stream() + .map(Category::fromString) + .toList(); + } + + private List pickRandomStudiesFromPreferred(List categories, int count) { + if (categories.isEmpty()) { + return new ArrayList<>(); + } + + final int fetchLimit = 20; + List candidates = studyQueryRepository.findRecruitingStudiesByCategories( + categories, fetchLimit); + + List shuffled = new ArrayList<>(candidates); + Collections.shuffle(shuffled); + + return new ArrayList<>(shuffled.stream().limit(count).toList()); + } + + private void fillWithPopularStudies(List result, int targetCount) { + if (result.size() >= targetCount) { + return; + } + + List excludeIds = result.stream().map(Study::getId).toList(); + int remaining = targetCount - result.size(); + + List popularStudies = studyQueryRepository.findPopularRecruitingStudies( + excludeIds, remaining); + result.addAll(popularStudies); } - private GetStudyOverviewResponse toCursorPage(List rows, int pageSize, - Long totalElements) { + private GetStudyOverviewResponse toCursorPage(List rows, long viewerId, int pageSize, + long totalElements) { boolean hasNext = rows.size() > pageSize; List pageContent = hasNext ? rows.subList(0, pageSize) : rows; Long nextCursor = hasNext ? pageContent.getLast().getId() : null; - return StudyDTOMapper.toDTO(pageContent, hasNext, nextCursor, totalElements); + Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); + return StudyDTOMapper.toDTO(pageContent, likedStudyIds, hasNext, nextCursor, totalElements); } private List filterPreferredRegionCodes(List regionCodes, diff --git a/modules/study/src/main/java/kr/spot/study/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java index 4c74946..f40a161 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -57,17 +57,25 @@ public class Study extends BaseEntity { @Enumerated(EnumType.STRING) private RecruitingStatus recruitingStatus; + @Column(nullable = false) + private Boolean isOnline = false; + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String description) { - return of(id, leaderId, name, maxMembers, fee, null, description); + return of(id, leaderId, name, maxMembers, fee, null, description, false); } public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String imageUrl, String description) { + return of(id, leaderId, name, maxMembers, fee, imageUrl, description, false); + } + + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, + String imageUrl, String description, Boolean isOnline) { validateStudyNameIsNotBlank(name); validateMaxMembers(maxMembers); return new Study(id, leaderId, name, maxMembers, CURRENT_MEMBERS, fee, imageUrl, description, - RecruitingStatus.RECRUITING); + RecruitingStatus.RECRUITING, isOnline != null && isOnline); } private static void validateStudyNameIsNotBlank(String name) { diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java index cbb7bab..abb71e6 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java @@ -30,4 +30,7 @@ int hardDelete(@Param("studyId") long studyId, @Modifying @Query(value = "DELETE FROM study_like WHERE member_id = :memberId", nativeQuery = true) void deleteAllByMemberId(@Param("memberId") long memberId); + + @Query("SELECT sl.studyId FROM StudyLike sl WHERE sl.memberId = :memberId") + java.util.Set findStudyIdsByMemberId(@Param("memberId") long memberId); } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index cda33d5..85c4c7d 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -69,6 +69,7 @@ public List findMyPreferredRegionStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, SortBy sortBy, Long cursor, int limit, @@ -90,6 +91,7 @@ public List findMyPreferredRegionStudies( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), ltCursor(cursor, study) ) .groupBy(study.id) @@ -105,6 +107,7 @@ public long countMyPreferredRegionStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, List regionCodes ) { QStudy study = QStudy.study; @@ -120,7 +123,8 @@ public long countMyPreferredRegionStudies( studyRegion.regionCode.in(regionCodes), eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) ) .fetchOne(); } @@ -128,6 +132,7 @@ public long countMyPreferredRegionStudies( public List findMyPreferredCategoryStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, SortBy sortBy, Long cursor, int limit, @@ -146,6 +151,7 @@ public List findMyPreferredCategoryStudies( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), ltCursor(cursor, study) ) .groupBy(study.id) @@ -160,6 +166,7 @@ public List findMyPreferredCategoryStudies( public long countMyPreferredCategoryStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, List categories ) { QStudy study = QStudy.study; @@ -172,7 +179,118 @@ public long countMyPreferredCategoryStudies( .where( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + eqFeeCategory(feeCategory, study), + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .groupBy(study.id) + .orderBy( + orderBy(sortBy, study, studyStats), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study.id.countDistinct()) + .from(study) + .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + eqFeeCategory(feeCategory, study), + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqCategory(category, studyCategory), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .groupBy(study.id) + .orderBy( + orderBy(sortBy, study, studyStats), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study.id.countDistinct()) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqCategory(category, studyCategory), + eqIsOnline(isOnline, study) ) .fetchOne(); } @@ -192,6 +310,14 @@ private BooleanExpression inCategories(List categories, QStudyCategory return studyCategory.category.in(categories); } + private BooleanExpression eqCategory(Category category, QStudyCategory studyCategory) { + return category == null ? null : studyCategory.category.eq(category); + } + + private BooleanExpression eqIsOnline(Boolean isOnline, QStudy study) { + return isOnline == null ? null : study.isOnline.eq(isOnline); + } + private BooleanExpression ltCursor(Long cursor, QStudy study) { return cursor == null ? null : study.id.lt(cursor); } @@ -236,4 +362,46 @@ public long countLikedStudies(long memberId) { .where(studyLike.memberId.eq(memberId)) .fetchOne(); } + + public List findRecruitingStudiesByCategories(List categories, int limit) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + studyCategory.category.in(categories), + study.recruitingStatus.eq(RecruitingStatus.RECRUITING) + ) + .groupBy(study.id) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public List findPopularRecruitingStudies(List excludeIds, int limit) { + QStudy study = QStudy.study; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + notInIds(excludeIds, study) + ) + .orderBy(studyStats.likeCount.desc(), study.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression notInIds(List excludeIds, QStudy study) { + if (excludeIds == null || excludeIds.isEmpty()) { + return null; + } + return study.id.notIn(excludeIds); + } } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java index 551eba5..0609485 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java @@ -10,6 +10,7 @@ public record CreateStudyRequest( Boolean hasFee, Integer amount, String description, + Boolean isOnline, Set categories, Set