diff --git a/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java index a61a068b..bac1def6 100644 --- a/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java +++ b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java @@ -12,6 +12,8 @@ import java.util.List; public interface StudentAdminRepository extends JpaRepository { + + // 총 누적 가입자 수 @Query(""" select count(sa) from StudentAdmin sa @@ -19,7 +21,7 @@ select count(sa) """) Long countAllByAdminId(@Param("adminId") Long adminId); - + // 기간별 가입자 수 @Query(""" select count(sa) from StudentAdmin sa @@ -31,12 +33,14 @@ Long countByAdminIdBetween(@Param("adminId") Long adminId, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + // 이번 달 신규 가입자 수 default Long countThisMonthByAdminId(Long adminId) { LocalDateTime from = YearMonth.now().atDay(1).atStartOfDay(); LocalDateTime to = LocalDateTime.now(); return countByAdminIdBetween(adminId, from, to); } - // 오늘 하루, '나를 admin으로 제휴 맺은 partner'의 제휴를 사용한 '고유 사용자 수' + + // 오늘 제휴 사용 고유 사용자 수 @Query(value = """ SELECT COUNT(DISTINCT pu.student_id) FROM partnership_usage pu @@ -48,25 +52,44 @@ SELECT COUNT(DISTINCT pu.student_id) """, nativeQuery = true) Long countTodayUsersByAdmin(@Param("adminId") Long adminId); - // 누적: admin이 제휴한 모든 store의 사용 건수 (0건 포함), 사용량 내림차순 @Query(value = """ SELECT + p.id AS paperId, p.store_id AS storeId, s.name AS storeName, + CAST(COUNT(pu.id) AS UNSIGNED) AS usageCount + FROM paper p + JOIN store s ON s.id = p.store_id + JOIN paper_content pc ON pc.paper_id = p.id + JOIN partnership_usage pu ON pu.paper_id = pc.id + WHERE p.admin_id = :adminId + GROUP BY p.id, p.store_id, s.name + HAVING usageCount > 0 + ORDER BY usageCount DESC, p.id ASC + """, nativeQuery = true) + List findUsageByStoreWithPaper(@Param("adminId") Long adminId); + + // 0건 포함 조회 (대시보드에서 모든 제휴 업체를 보여줘야 하는 경우) + @Query(value = """ + SELECT + p.id AS paperId, + p.store_id AS storeId, + s.name AS storeName, CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount FROM paper p JOIN store s ON s.id = p.store_id LEFT JOIN paper_content pc ON pc.paper_id = p.id LEFT JOIN partnership_usage pu ON pu.paper_id = pc.id WHERE p.admin_id = :adminId - GROUP BY p.store_id, s.name - ORDER BY usageCount DESC, storeId ASC + GROUP BY p.id, p.store_id, s.name + ORDER BY usageCount DESC, p.id ASC """, nativeQuery = true) - List findUsageByStore(@Param("adminId") Long adminId); + List findUsageByStoreIncludingZero(@Param("adminId") Long adminId); - interface StoreUsage { + interface StoreUsageWithPaper { + Long getPaperId(); // 🆕 추가: Paper ID Long getStoreId(); String getStoreName(); Long getUsageCount(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java index 5c91bb18..2eb03ee3 100644 --- a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java +++ b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java @@ -6,6 +6,7 @@ import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; import com.assu.server.domain.mapping.repository.StudentAdminRepository; import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.partnership.repository.PartnershipRepository; import com.assu.server.domain.user.service.StudentService; import com.assu.server.global.apiPayload.code.status.ErrorStatus; @@ -15,6 +16,8 @@ import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @Transactional @@ -22,69 +25,92 @@ public class StudentAdminServiceImpl implements StudentAdminService { private final StudentAdminRepository studentAdminRepository; private final AdminRepository adminRepository; - private final PartnershipRepository partnershipRepository; + private final PaperRepository paperRepository; @Override @Transactional public StudentAdminResponseDTO.CountAdminAuthResponseDTO getCountAdminAuth(Long memberId) { - + Admin admin = getAdminOrThrow(memberId); Long total = studentAdminRepository.countAllByAdminId(memberId); - Admin admin = adminRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); - String adminName = admin.getName(); - return StudentAdminConverter.countAdminAuthDTO(memberId, total, adminName); + return StudentAdminConverter.countAdminAuthDTO(memberId, total, admin.getName()); } + @Override @Transactional public StudentAdminResponseDTO.NewCountAdminResponseDTO getNewStudentCountAdmin(Long memberId) { - + Admin admin = getAdminOrThrow(memberId); Long total = studentAdminRepository.countThisMonthByAdminId(memberId); - Admin admin = adminRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); - String adminName = admin.getName(); - return StudentAdminConverter.newCountAdminResponseDTO(memberId, total, adminName); + + return StudentAdminConverter.newCountAdminResponseDTO(memberId, total, admin.getName()); } @Override @Transactional public StudentAdminResponseDTO.CountUsagePersonResponseDTO getCountUsagePerson(Long memberId) { - + Admin admin = getAdminOrThrow(memberId); Long total = studentAdminRepository.countTodayUsersByAdmin(memberId); - Admin admin = adminRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); - String adminName =admin.getName(); - return StudentAdminConverter.countUsagePersonDTO(memberId, total, adminName); + + return StudentAdminConverter.countUsagePersonDTO(memberId, total, admin.getName()); } @Override @Transactional public StudentAdminResponseDTO.CountUsageResponseDTO getCountUsage(Long memberId) { - Admin admin = adminRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); - String adminName =admin.getName(); - List storeUsages = studentAdminRepository.findUsageByStore(memberId); + Admin admin = getAdminOrThrow(memberId); + + List storeUsages = + studentAdminRepository.findUsageByStoreWithPaper(memberId); + + //예외 처리 + if (storeUsages.isEmpty()) { + throw new DatabaseException(ErrorStatus.NO_USAGE_DATA); + } + + // 첫 번째가 가장 사용량이 많은 업체 (ORDER BY usageCount DESC) var top = storeUsages.get(0); - Paper paper = partnershipRepository.findFirstByAdmin_IdAndStore_IdOrderByIdAsc(memberId, top.getStoreId()) + + Paper paper = paperRepository.findById(top.getPaperId()) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE)); - Long total = top.getUsageCount(); - return StudentAdminConverter.countUsageResponseDTO(admin, paper, total); + return StudentAdminConverter.countUsageResponseDTO(admin, paper, top.getUsageCount()); } @Override @Transactional public StudentAdminResponseDTO.CountUsageListResponseDTO getCountUsageList(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + + // 🔧 핵심 수정: Paper 정보를 포함한 조회 (N+1 해결) + List storeUsages = + studentAdminRepository.findUsageByStoreWithPaper(memberId); + + if (storeUsages.isEmpty()) { + // 빈 리스트 반환 (선택: 예외 처리도 가능) + return StudentAdminConverter.countUsageListResponseDTO(List.of()); + } + + List paperIds = storeUsages.stream() + .map(StudentAdminRepository.StoreUsageWithPaper::getPaperId) + .toList(); + + Map paperMap = paperRepository.findAllById(paperIds).stream() + .collect(Collectors.toMap(Paper::getId, paper -> paper)); - Admin admin = adminRepository.findById(memberId) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); - List storeUsages = studentAdminRepository.findUsageByStore(memberId); var items = storeUsages.stream().map(row -> { - Paper paper = partnershipRepository.findFirstByAdmin_IdAndStore_IdOrderByIdAsc(memberId, row.getStoreId()) - .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE)); + Paper paper = paperMap.get(row.getPaperId()); + if (paper == null) { + throw new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE); + } return StudentAdminConverter.countUsageResponseDTO(admin, paper, row.getUsageCount()); }).toList(); + return StudentAdminConverter.countUsageListResponseDTO(items); } -} + // Admin 조회 중복 제거 + private Admin getAdminOrThrow(Long adminId) { + return adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java index 597b036c..5a28476b 100644 --- a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java +++ b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java @@ -19,13 +19,7 @@ public static class WeeklyRankResponseDTO { private Long rank; // 그 주 순위(1부터) private Long usageCount; // 그 주 사용 건수 } - @AllArgsConstructor - @RequiredArgsConstructor - @Builder - @Getter - public static class todayBest{ - List bestStores; - } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -35,5 +29,12 @@ public static class ListWeeklyRankResponseDTO { private String storeName; private List items; // 과거→현재 (6개) } + @AllArgsConstructor + @RequiredArgsConstructor + @Builder + @Getter + public static class todayBest{ + List bestStores; + } } diff --git a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java index 2bf0bc05..7114810e 100644 --- a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java @@ -11,10 +11,12 @@ import java.util.List; public interface StoreRepository extends JpaRepository { - Optional findByPartner(Partner partner); + + Optional findByPartner(Partner partner); Optional findByNameAndAddressAndDetailAddress(String name, String address, String detailAddress); - // [이번 주] 전체 스토어 중 특정 storeId의 주간 순위/건수 1건 + + // [이번 주] 전체 스토어 중 특정 storeId의 주간 순위/건수 1건 (ACTIVE만) @Query(value = """ WITH w AS ( SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AS week_start @@ -26,10 +28,9 @@ per_store AS ( CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount, (SELECT week_start FROM w) AS weekStart FROM store s - LEFT JOIN paper p ON p.store_id = s.id - LEFT JOIN paper_content pc ON pc.paper_id = p.id + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' LEFT JOIN partnership_usage pu - ON pu.paper_id = pc.id + ON pu.paper_id = p.id AND pu.created_at >= (SELECT week_start FROM w) AND pu.created_at < (SELECT week_start FROM w) + INTERVAL 7 DAY GROUP BY s.id, s.name @@ -56,7 +57,7 @@ interface GlobalWeeklyRankRow { Long getStoreRank(); } - // [최근 6주] 전체 스토어 기준, 특정 storeId의 주간 순위/건수(월요일 시작) 추세 + // [최근 6주] 전체 스토어 기준, 특정 storeId의 주간 순위/건수(월요일 시작) 추세 (ACTIVE만) @Query(value = """ WITH RECURSIVE weeks AS ( SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AS week_start @@ -71,11 +72,10 @@ per_store_week AS ( s.name AS storeName, CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount FROM weeks w - JOIN store s ON 1=1 - LEFT JOIN paper p ON p.store_id = s.id - LEFT JOIN paper_content pc ON pc.paper_id = p.id + JOIN store s ON 1=1 + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' LEFT JOIN partnership_usage pu - ON pu.paper_id = pc.id + ON pu.paper_id = p.id AND pu.created_at >= w.week_start AND pu.created_at < w.week_start + INTERVAL 7 DAY GROUP BY w.week_start, s.id, s.name @@ -102,7 +102,6 @@ per_store_week AS ( WHERE s.address = :address AND ((:detail IS NULL AND s.detailAddress IS NULL) OR s.detailAddress = :detail) """) - Optional findBySameAddress( @Param("address") String address, @Param("detail") String detail @@ -119,8 +118,21 @@ AND ST_Contains(ST_GeomFromText(:wkt, 4326), s.point) List findByNameContainingIgnoreCaseOrderByIdDesc(String name); Optional findByName(String name); Optional findById(Long id); - Optional findByPartnerId(Long partnerId); - -} + // [오늘] 전체 스토어 중 사용 건수 상위 10개 (ACTIVE만) + @Query(value = """ + SELECT s.name + FROM store s + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' + LEFT JOIN partnership_usage pu + ON pu.paper_id = p.id + AND pu.created_at >= CURDATE() + AND pu.created_at < CURDATE() + INTERVAL 1 DAY + GROUP BY s.id, s.name + HAVING COUNT(pu.id) > 0 + ORDER BY COUNT(pu.id) DESC, s.id ASC + LIMIT 10 + """, nativeQuery = true) + List findTodayBestStoreNames(); +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java index d1fc5eb2..232c21ba 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java @@ -26,7 +26,7 @@ public class StoreServiceImpl implements StoreService { @Override @Transactional public StoreResponseDTO.todayBest getTodayBestStore() { - List bestStores = partnershipUsageRepository.findTodayPopularPartnership(); + List bestStores = storeRepository.findTodayBestStoreNames(); return StoreResponseDTO.todayBest.builder() .bestStores(bestStores) diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java index 7ca4c047..9e396c56 100644 --- a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -114,7 +114,7 @@ public enum ErrorStatus implements BaseErrorCode { REVIEW_REPORT_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REPORT_4003", "자신의 리뷰를 신고할 수 없습니다."), SUGGESTION_REPORT_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REPORT_4004", "자신의 건의글을 신고할 수 없습니다."), INVALID_REPORT_TYPE(HttpStatus.BAD_REQUEST, "REPORT_4005", "유효하지 않은 신고 타입입니다."), - ; + NO_USAGE_DATA(HttpStatus.NOT_FOUND, "ADMIN4001", "해당 관리자의 제휴 이용 내역이 없습니다."); private final HttpStatus httpStatus; private final String code;