From 8c85b47791fb4a6d51035a6f54706e7a00d729ac Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 10:43:33 +0900 Subject: [PATCH 01/31] =?UTF-8?q?refactor/336=20=EC=9E=85=EA=B8=88=20?= =?UTF-8?q?=ED=99=98=EC=A0=84=20=EB=82=B4=EC=97=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 211 +++++++++++++++++- .../repository/CashTransactionRepository.java | 52 +++++ .../ArtistDashboardServiceImplTest.java | 201 ++++++++++++++++- 3 files changed, 453 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index 236df971..42be995a 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -51,6 +51,7 @@ public class ArtistDashboardServiceImpl implements ArtistDashboardService { private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository; private final com.back.domain.payment.settlement.repository.SettlementRepository settlementRepository; private final com.back.domain.user.repository.UserRepository userRepository; + private final com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; @Value("${google.analytics.property-id}") private String propertyId; @@ -467,10 +468,212 @@ public ArtistCashResponse.Balance getCashBalance(Long artistId) { @Override public ArtistCashHistoryResponse.List getCashHistory(Long artistId, ArtistCashHistorySearchRequest request) { - // TODO: 실제 데이터베이스 연동 필요 - log.info("작가 캐시 내역 조회 - artistId: {}, page: {}, size: {}, type: {}", - artistId, request.page(), request.size(), request.type()); - throw new UnsupportedOperationException("작가 캐시 거래 내역 조회는 아직 구현되지 않았습니다."); + log.info("작가 캐시 내역 조회 - artistId: {}, page: {}, size: {}, type: {}, status: {}, dateFrom: {}, dateTo: {}", + artistId, request.page(), request.size(), request.type(), request.status(), request.dateFrom(), request.dateTo()); + + // 1. 작가(User) 조회 + com.back.domain.user.entity.User artist = userRepository.findById(artistId) + .orElseThrow(() -> new com.back.global.exception.ServiceException("404", "작가를 찾을 수 없습니다.")); + + // 2. 필터 조건 변환 + com.back.domain.payment.cash.entity.CashTransactionType transactionType = null; + if (request.type() != null && !request.type().isBlank()) { + try { + // DEPOSIT -> CHARGING, WITHDRAWAL -> EXCHANGE + transactionType = switch (request.type()) { + case "DEPOSIT" -> com.back.domain.payment.cash.entity.CashTransactionType.CHARGING; + case "WITHDRAWAL" -> com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE; + default -> throw new IllegalArgumentException("Invalid transaction type: " + request.type()); + }; + } catch (IllegalArgumentException e) { + log.warn("잘못된 거래 유형: {}", request.type()); + } + } + + com.back.domain.payment.cash.entity.CashTransactionStatus status = null; + if (request.status() != null && !request.status().isBlank()) { + try { + status = com.back.domain.payment.cash.entity.CashTransactionStatus.valueOf(request.status()); + } catch (IllegalArgumentException e) { + log.warn("잘못된 거래 상태: {}", request.status()); + } + } + + // 3. 날짜 파싱 + LocalDateTime startDate = null; + LocalDateTime endDate = null; + + if (request.dateFrom() != null && !request.dateFrom().isBlank()) { + try { + startDate = java.time.LocalDate.parse(request.dateFrom()).atStartOfDay(); + } catch (java.time.format.DateTimeParseException e) { + log.warn("잘못된 시작 날짜 형식: {}", request.dateFrom()); + } + } + + if (request.dateTo() != null && !request.dateTo().isBlank()) { + try { + endDate = java.time.LocalDate.parse(request.dateTo()).atTime(23, 59, 59); + } catch (java.time.format.DateTimeParseException e) { + log.warn("잘못된 종료 날짜 형식: {}", request.dateTo()); + } + } + + // 4. 정렬 설정 + org.springframework.data.domain.Sort sort = createCashHistorySort(request.sort(), request.order()); + PageRequest pageRequest = PageRequest.of(request.page(), request.size(), sort); + + // 5. Repository를 통한 실제 DB 조회 + Page transactionPage = + cashTransactionRepository.findCashTransactionsByUserWithFilters( + artist, + transactionType, + status, + startDate, + endDate, + pageRequest + ); + + // 6. Entity → DTO 변환 + List content = transactionPage.getContent().stream() + .map(this::convertToCashHistoryDto) + .toList(); + + // 7. 기간별 요약 계산 + ArtistCashHistoryResponse.Summary summary = calculateCashHistorySummary( + artist, startDate, endDate); + + int totalPages = transactionPage.getTotalPages(); + long totalElements = transactionPage.getTotalElements(); + boolean hasNext = transactionPage.hasNext(); + boolean hasPrevious = transactionPage.hasPrevious(); + + log.info("작가 캐시 내역 조회 완료 - 조회된 거래 수: {}, 전체: {}", content.size(), totalElements); + + return new ArtistCashHistoryResponse.List( + summary, + content, + request.page(), + request.size(), + totalElements, + totalPages, + hasNext, + hasPrevious + ); + } + + /** + * CashTransaction 정렬 생성 + */ + private org.springframework.data.domain.Sort createCashHistorySort(String sortField, String sortOrder) { + if (sortField == null || sortField.isBlank()) { + sortField = "transactedAt"; + } + if (sortOrder == null || sortOrder.isBlank()) { + sortOrder = "DESC"; + } + + org.springframework.data.domain.Sort.Direction direction = + "ASC".equalsIgnoreCase(sortOrder) + ? org.springframework.data.domain.Sort.Direction.ASC + : org.springframework.data.domain.Sort.Direction.DESC; + + return switch (sortField) { + case "amount" -> org.springframework.data.domain.Sort.by(direction, "amount"); + case "type" -> org.springframework.data.domain.Sort.by(direction, "transactionType"); + case "status" -> org.springframework.data.domain.Sort.by(direction, "status"); + default -> org.springframework.data.domain.Sort.by(direction, "completedAt", "createDate"); + }; + } + + /** + * CashTransaction 엔티티를 Transaction DTO로 변환 + */ + private ArtistCashHistoryResponse.Transaction convertToCashHistoryDto( + com.back.domain.payment.cash.entity.CashTransaction transaction) { + + // 거래 일시 (완료 시간 우선, 없으면 생성 시간) + String transactedAt = transaction.getCompletedAt() != null + ? transaction.getCompletedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm")) + : transaction.getCreateDate().format(java.time.format.DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm")); + + // 거래 유형 변환 (CHARGING -> DEPOSIT, EXCHANGE -> WITHDRAWAL) + String type = transaction.isCharging() ? "DEPOSIT" : "WITHDRAWAL"; + String typeText = transaction.isCharging() ? "입금" : "환전"; + + // 입금액/환전액 구분 + int depositAmount = transaction.isCharging() ? transaction.getAmount() : 0; + int withdrawalAmount = transaction.isExchange() ? transaction.getAmount() : 0; + + // 거래 후 잔액 + int balanceAfter = transaction.getBalanceAfter() != null ? transaction.getBalanceAfter() : 0; + + // 거래 방법 + String method = transaction.getPaymentMethod() != null ? transaction.getPaymentMethod() : "UNKNOWN"; + String methodText = convertPaymentMethodToKorean(transaction.getPaymentMethod()); + + // 상태 + String status = transaction.getStatus().name(); + + // 메모 (실패 사유, 취소 사유 등) + String note = ""; + if (transaction.getStatus() == com.back.domain.payment.cash.entity.CashTransactionStatus.FAILED) { + note = transaction.getFailureReason() != null ? transaction.getFailureReason() : "처리 실패"; + } else if (transaction.getStatus() == com.back.domain.payment.cash.entity.CashTransactionStatus.CANCELLED) { + note = transaction.getCancellationReason() != null ? transaction.getCancellationReason() : "처리 취소"; + } + + return new ArtistCashHistoryResponse.Transaction( + transaction.getId().toString(), + transactedAt, + type, + typeText, + depositAmount, + withdrawalAmount, + balanceAfter, + method, + methodText, + status, + note + ); + } + + /** + * 결제 수단을 한글로 변환 + * 작가 대시보드에서는 모리캐시(정산금 입금)와 계좌이체(환전) 2가지만 사용 + */ + private String convertPaymentMethodToKorean(String paymentMethod) { + if (paymentMethod == null) { + return "기타"; + } + + // 입금 수단: 모리캐시 (정산금이 모리캐시로 입금됨) + // 환전 수단: 계좌이체 (모리캐시를 실제 계좌로 환전) + return switch (paymentMethod.toUpperCase()) { + case "WALLET", "MORICASH" -> "모리캐시"; + case "BANK_TRANSFER", "BANK" -> "계좌이체"; + default -> paymentMethod; + }; + } + + /** + * 기간별 입금/환전 요약 계산 + */ + private ArtistCashHistoryResponse.Summary calculateCashHistorySummary( + com.back.domain.user.entity.User artist, + LocalDateTime startDate, + LocalDateTime endDate) { + + // CashTransactionRepository 사용 + int periodDepositTotal = cashTransactionRepository.getPeriodDepositTotal(artist, startDate, endDate); + int periodWithdrawalTotal = cashTransactionRepository.getPeriodWithdrawalTotal(artist, startDate, endDate); + int periodNet = periodDepositTotal - periodWithdrawalTotal; + + return new ArtistCashHistoryResponse.Summary( + periodDepositTotal, + periodWithdrawalTotal, + periodNet + ); } @Override diff --git a/src/main/java/com/back/domain/payment/cash/repository/CashTransactionRepository.java b/src/main/java/com/back/domain/payment/cash/repository/CashTransactionRepository.java index 65545103..885be51b 100644 --- a/src/main/java/com/back/domain/payment/cash/repository/CashTransactionRepository.java +++ b/src/main/java/com/back/domain/payment/cash/repository/CashTransactionRepository.java @@ -70,4 +70,56 @@ List findByTransactionTypeAndStatus(@Param("transactionType") C "AND ct.status = 'COMPLETED' " + "ORDER BY ct.completedAt DESC, ct.createDate DESC") List findCompletedChargingByUser(@Param("user") User user); + + /** + * 작가별 캐시 거래 내역 조회 (페이징, 다중 조건 필터링, 동적 정렬) + * @param user 사용자 + * @param transactionType 거래 유형 (CHARGING, EXCHANGE) + * @param status 거래 상태 (PENDING, COMPLETED, FAILED, CANCELLED) + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @param pageable 페이징 및 정렬 정보 + * @return 조회된 거래 내역 페이지 + */ + @Query("SELECT ct FROM CashTransaction ct " + + "WHERE ct.user = :user " + + "AND (:transactionType IS NULL OR ct.transactionType = :transactionType) " + + "AND (:status IS NULL OR ct.status = :status) " + + "AND (:startDate IS NULL OR ct.createDate >= :startDate) " + + "AND (:endDate IS NULL OR ct.createDate <= :endDate)") + Page findCashTransactionsByUserWithFilters( + @Param("user") User user, + @Param("transactionType") CashTransactionType transactionType, + @Param("status") CashTransactionStatus status, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + Pageable pageable); + + /** + * 기간 내 사용자별 입금(충전) 합계 + */ + @Query("SELECT COALESCE(SUM(ct.amount), 0) FROM CashTransaction ct " + + "WHERE ct.user = :user " + + "AND ct.transactionType = 'CHARGING' " + + "AND ct.status = 'COMPLETED' " + + "AND (:startDate IS NULL OR ct.completedAt >= :startDate) " + + "AND (:endDate IS NULL OR ct.completedAt <= :endDate)") + Integer getPeriodDepositTotal( + @Param("user") User user, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); + + /** + * 기간 내 사용자별 환전 합계 + */ + @Query("SELECT COALESCE(SUM(ct.amount), 0) FROM CashTransaction ct " + + "WHERE ct.user = :user " + + "AND ct.transactionType = 'EXCHANGE' " + + "AND ct.status = 'COMPLETED' " + + "AND (:startDate IS NULL OR ct.completedAt >= :startDate) " + + "AND (:endDate IS NULL OR ct.completedAt <= :endDate)") + Integer getPeriodWithdrawalTotal( + @Param("user") User user, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate); } diff --git a/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java index 70943737..1f0dd530 100644 --- a/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java @@ -84,6 +84,9 @@ class ArtistDashboardServiceImplTest { @Autowired private com.back.domain.artist.repository.ArtistApplicationRepository artistApplicationRepository; + @Autowired + private com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; + private User testArtist; private User testCustomer; private Category testCategory; @@ -283,22 +286,206 @@ void getFundings_ReturnsRealData() { ); } - // ==================== 설정 테스트 ==================== + // ==================== 입금/환전 내역 테스트 ==================== @Test - @DisplayName("작가 설정 조회 - 계좌번호 마스킹 검증") - void getSettings_AccountMasking() { + @DisplayName("입금/환전 내역 조회 - 실제 DB 데이터 검증") + void getCashHistory_ReturnsRealData() { + // Given - 입금 거래 생성 + com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(10000) + .paymentMethod("TOSS") + .pgProvider("TOSS") + .balanceAfter(10000) + .build(); + depositTx.completeTransaction("TX001", "APPROVAL001", 10000); + cashTransactionRepository.save(depositTx); + + // Given - 환전 거래 생성 + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) + .amount(5000) + .paymentMethod("BANK_TRANSFER") + .balanceAfter(5000) + .build(); + withdrawalTx.completeTransaction("TX002", "APPROVAL002", 5000); + cashTransactionRepository.save(withdrawalTx); + + ArtistCashHistorySearchRequest request = new ArtistCashHistorySearchRequest( + 0, 10, null, null, null, null, "transactedAt", "DESC"); + // When - ArtistSettingsResponse result = artistDashboardService.getSettings(testArtist.getId()); + ArtistCashHistoryResponse.List result = artistDashboardService.getCashHistory( + testArtist.getId(), request); // Then assertAll( - () -> assertThat(result.profile().nickname()).isEqualTo("테스트작가"), - () -> assertThat(result.payout().accountMasked()).isEqualTo("****-****-**9012"), - () -> assertThat(result.payout().bankName()).isEqualTo("테스트은행") + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.summary()).isNotNull(), + () -> assertThat(result.summary().periodDepositTotal()).isEqualTo(10000), + () -> assertThat(result.summary().periodWithdrawalTotal()).isEqualTo(5000), + () -> assertThat(result.summary().periodNet()).isEqualTo(5000) + ); + } + + @Test + @DisplayName("입금/환전 내역 조회 - 거래 유형 필터링 (입금)") + void getCashHistory_FiltersByDeposit() { + // Given + com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(20000) + .paymentMethod("CARD") + .balanceAfter(20000) + .build(); + depositTx.completeTransaction("TX003", "APPROVAL003", 20000); + cashTransactionRepository.save(depositTx); + + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) + .amount(10000) + .paymentMethod("BANK_TRANSFER") + .balanceAfter(10000) + .build(); + withdrawalTx.completeTransaction("TX004", "APPROVAL004", 10000); + cashTransactionRepository.save(withdrawalTx); + + ArtistCashHistorySearchRequest request = new ArtistCashHistorySearchRequest( + 0, 10, "DEPOSIT", null, null, null, "transactedAt", "DESC"); + + // When + ArtistCashHistoryResponse.List result = artistDashboardService.getCashHistory( + testArtist.getId(), request); + + // Then + assertAll( + () -> assertThat(result.content()).hasSize(1), + () -> assertThat(result.content().get(0).type()).isEqualTo("DEPOSIT"), + () -> assertThat(result.content().get(0).typeText()).isEqualTo("입금"), + () -> assertThat(result.content().get(0).depositAmount()).isEqualTo(20000), + () -> assertThat(result.content().get(0).withdrawalAmount()).isEqualTo(0) + ); + } + + @Test + @DisplayName("입금/환전 내역 조회 - 거래 유형 필터링 (환전)") + void getCashHistory_FiltersByWithdrawal() { + // Given + com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(30000) + .paymentMethod("TOSS") + .balanceAfter(30000) + .build(); + depositTx.completeTransaction("TX005", "APPROVAL005", 30000); + cashTransactionRepository.save(depositTx); + + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) + .amount(15000) + .paymentMethod("BANK_TRANSFER") + .balanceAfter(15000) + .build(); + withdrawalTx.completeTransaction("TX006", "APPROVAL006", 15000); + cashTransactionRepository.save(withdrawalTx); + + ArtistCashHistorySearchRequest request = new ArtistCashHistorySearchRequest( + 0, 10, "WITHDRAWAL", null, null, null, "transactedAt", "DESC"); + + // When + ArtistCashHistoryResponse.List result = artistDashboardService.getCashHistory( + testArtist.getId(), request); + + // Then + assertAll( + () -> assertThat(result.content()).hasSize(1), + () -> assertThat(result.content().get(0).type()).isEqualTo("WITHDRAWAL"), + () -> assertThat(result.content().get(0).typeText()).isEqualTo("환전"), + () -> assertThat(result.content().get(0).depositAmount()).isEqualTo(0), + () -> assertThat(result.content().get(0).withdrawalAmount()).isEqualTo(15000) + ); + } + + @Test + @DisplayName("입금/환전 내역 조회 - 상태 필터링") + void getCashHistory_FiltersByStatus() { + // Given - 완료된 거래 + com.back.domain.payment.cash.entity.CashTransaction completedTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(50000) + .paymentMethod("CARD") + .balanceAfter(50000) + .build(); + completedTx.completeTransaction("TX007", "APPROVAL007", 50000); + cashTransactionRepository.save(completedTx); + + // Given - 대기 중인 거래 + com.back.domain.payment.cash.entity.CashTransaction pendingTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) + .amount(25000) + .paymentMethod("BANK_TRANSFER") + .build(); + cashTransactionRepository.save(pendingTx); + + ArtistCashHistorySearchRequest request = new ArtistCashHistorySearchRequest( + 0, 10, null, "COMPLETED", null, null, "transactedAt", "DESC"); + + // When + ArtistCashHistoryResponse.List result = artistDashboardService.getCashHistory( + testArtist.getId(), request); + + // Then + assertAll( + () -> assertThat(result.content()).hasSize(1), + () -> assertThat(result.content().get(0).status()).isEqualTo("COMPLETED") ); } + @Test + @DisplayName("입금/환전 내역 조회 - 날짜 범위 필터링") + void getCashHistory_FiltersByDateRange() { + // Given - 오늘 거래 + com.back.domain.payment.cash.entity.CashTransaction todayTx = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testArtist) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(10000) + .paymentMethod("TOSS") + .balanceAfter(10000) + .build(); + todayTx.completeTransaction("TX008", "APPROVAL008", 10000); + cashTransactionRepository.save(todayTx); + + String today = java.time.LocalDate.now().toString(); + + ArtistCashHistorySearchRequest request = new ArtistCashHistorySearchRequest( + 0, 10, null, null, today, today, "transactedAt", "DESC"); + + // When + ArtistCashHistoryResponse.List result = artistDashboardService.getCashHistory( + testArtist.getId(), request); + + // Then + assertThat(result.content()).isNotEmpty(); + } + // ==================== 주문 내역 테스트 ==================== @Test From 393dd8538aa60fd2347da4df80c97f7c52526b10 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 11:01:40 +0900 Subject: [PATCH 02/31] =?UTF-8?q?refactor/336=20=EC=9E=85=EA=B8=88=20?= =?UTF-8?q?=ED=99=98=EC=A0=84=20=EB=82=B4=EC=97=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 198 ++++++++------ .../order/repository/OrderRepository.java | 18 ++ .../service/ArtistSettlementServiceTest.java | 250 ++++++++++++------ 3 files changed, 300 insertions(+), 166 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index 42be995a..4c4cf679 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -1534,28 +1534,19 @@ public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSe endDate = LocalDateTime.of(year, 12, 31, 23, 59, 59); } - // 4. 요약 정보 조회 (해당 기간의 정산 데이터 집계) - // MoriCashBalance가 아닌 실제 Settlement 데이터에서 계산 - org.springframework.data.domain.Page allSettlements = - settlementRepository.findByArtistAndStatusAndCompletedAtBetween( - artist, - com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED, - startDate, - endDate, - org.springframework.data.domain.PageRequest.of(0, Integer.MAX_VALUE) - ); + // 4. 요약 정보 조회 (배송 완료된 주문에서 계산) + List deliveredOrders = + orderRepository.findDeliveredOrdersByArtistInPeriod(artist, startDate, endDate); - int totalSales = allSettlements.getContent().stream() - .mapToInt(com.back.domain.payment.settlement.entity.Settlement::getRequestedAmount) - .sum(); - - int totalCommission = allSettlements.getContent().stream() - .mapToInt(com.back.domain.payment.settlement.entity.Settlement::getCommissionAmount) + // 작가의 상품 매출만 계산 + int totalSales = deliveredOrders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .filter(item -> item.getProduct().getUser().getId().equals(artistId)) + .mapToInt(item -> item.getPrice().intValue() * item.getQuantity()) .sum(); - int totalNetIncome = allSettlements.getContent().stream() - .mapToInt(com.back.domain.payment.settlement.entity.Settlement::getNetAmount) - .sum(); + int totalCommission = totalSales / 10; // 10% 수수료 + int totalNetIncome = totalSales - totalCommission; ArtistSettlementResponse.Summary summary = new ArtistSettlementResponse.Summary( new ArtistSettlementResponse.AmountInfo(totalSales, "총 매출"), @@ -1564,11 +1555,11 @@ public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSe ); // 5. 차트 데이터 (월별 집계) - ArtistSettlementResponse.Chart chart = createSettlementChart(artistId, year, month); + ArtistSettlementResponse.Chart chart = createSettlementChartFromOrders(artistId, year, month); // 6. 테이블 데이터 (정산 내역 목록) - ArtistSettlementResponse.Table table = createSettlementTable( - artist, startDate, endDate, request + ArtistSettlementResponse.Table table = createSettlementTableFromOrders( + artist, startDate, endDate, request, artistId ); log.info("작가 정산 내역 조회 완료 - artistId: {}, 총매출: {}, 수수료: {}, 순수익: {}", @@ -1586,8 +1577,9 @@ public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSe /** * 정산 차트 데이터 생성 (월별 매출 그래프 - 1월~12월) + * Order 데이터에서 직접 조회 */ - private ArtistSettlementResponse.Chart createSettlementChart(Long artistId, Integer year, Integer month) { + private ArtistSettlementResponse.Chart createSettlementChartFromOrders(Long artistId, Integer year, Integer month) { List salesPoints = new ArrayList<>(); // 작가 조회 @@ -1599,18 +1591,15 @@ private ArtistSettlementResponse.Chart createSettlementChart(Long artistId, Inte LocalDateTime monthStart = LocalDateTime.of(year, m, 1, 0, 0, 0); LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1); - // 해당 월의 특정 작가 정산 합계 조회 - org.springframework.data.domain.Page settlements = - settlementRepository.findByArtistAndStatusAndCompletedAtBetween( - artist, - com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED, - monthStart, - monthEnd, - org.springframework.data.domain.PageRequest.of(0, Integer.MAX_VALUE) - ); - - int monthTotal = settlements.getContent().stream() - .mapToInt(com.back.domain.payment.settlement.entity.Settlement::getRequestedAmount) + // 해당 월의 배송 완료된 주문 조회 + List monthOrders = + orderRepository.findDeliveredOrdersByArtistInPeriod(artist, monthStart, monthEnd); + + // 작가의 상품 매출만 계산 + int monthTotal = monthOrders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .filter(item -> item.getProduct().getUser().getId().equals(artistId)) + .mapToInt(item -> item.getPrice().intValue() * item.getQuantity()) .sum(); salesPoints.add(new ArtistSettlementResponse.ChartDataPoint( @@ -1634,94 +1623,131 @@ private ArtistSettlementResponse.Chart createSettlementChart(Long artistId, Inte } /** - * 정산 테이블 데이터 생성 + * 정산 테이블 데이터 생성 (Order 기반) */ - private ArtistSettlementResponse.Table createSettlementTable( + private ArtistSettlementResponse.Table createSettlementTableFromOrders( com.back.domain.user.entity.User artist, LocalDateTime startDate, LocalDateTime endDate, - ArtistSettlementSearchRequest request) { + ArtistSettlementSearchRequest request, + Long artistId) { + + // 1. 배송 완료된 주문 조회 (정렬 없이 전체 조회) + List allOrders = + orderRepository.findDeliveredOrdersByArtistInPeriod(artist, startDate, endDate); + + // 2. 주문을 OrderItem 단위로 변환 (작가의 상품만) + List settlementItems = allOrders.stream() + .flatMap(order -> order.getOrderItems().stream() + .filter(item -> item.getProduct().getUser().getId().equals(artistId)) + .map(item -> new SettlementOrderItem( + order.getId(), + order.getOrderDate(), + item.getProduct().getId(), + item.getProduct().getName(), + item.getPrice().intValue() * item.getQuantity() + ))) + .toList(); - // 1. 정렬 설정 - org.springframework.data.domain.Sort sort = createSettlementSort(request.sort(), request.order()); - PageRequest pageRequest = PageRequest.of(request.page(), request.size(), sort); + // 3. 정렬 적용 + settlementItems = sortSettlementItems(settlementItems, request.sort(), request.order()); - // 2. Settlement 조회 - Page settlementPage = - settlementRepository.findByArtistAndStatusAndCompletedAtBetween( - artist, - com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED, - startDate, - endDate, - pageRequest - ); + // 4. 페이징 처리 + int start = request.page() * request.size(); + int end = Math.min(start + request.size(), settlementItems.size()); + List pagedItems = start < settlementItems.size() + ? settlementItems.subList(start, end) + : List.of(); - // 3. DTO 변환 - List content = settlementPage.getContent().stream() - .map(this::convertToSettlementDto) + // 5. DTO 변환 + List content = pagedItems.stream() + .map(this::convertToSettlementDtoFromOrder) .toList(); + int totalElements = settlementItems.size(); + int totalPages = (int) Math.ceil((double) totalElements / request.size()); + boolean hasNext = request.page() < totalPages - 1; + boolean hasPrevious = request.page() > 0; + return new ArtistSettlementResponse.Table( content, request.page(), request.size(), - (int) settlementPage.getTotalElements(), - settlementPage.getTotalPages(), - settlementPage.hasNext(), - settlementPage.hasPrevious() + totalElements, + totalPages, + hasNext, + hasPrevious ); } /** - * 정산 정렬 생성 + * 정산 아이템 정렬 */ - private org.springframework.data.domain.Sort createSettlementSort(String sortField, String sortOrder) { - org.springframework.data.domain.Sort.Direction direction = - "ASC".equalsIgnoreCase(sortOrder) - ? org.springframework.data.domain.Sort.Direction.ASC - : org.springframework.data.domain.Sort.Direction.DESC; - - return switch (sortField) { - case "grossAmount" -> org.springframework.data.domain.Sort.by(direction, "requestedAmount"); - case "commission" -> org.springframework.data.domain.Sort.by(direction, "commissionAmount"); - case "netAmount" -> org.springframework.data.domain.Sort.by(direction, "netAmount"); - case "status" -> org.springframework.data.domain.Sort.by(direction, "status"); - default -> org.springframework.data.domain.Sort.by(direction, "completedAt"); - }; + private List sortSettlementItems( + List items, String sortField, String sortOrder) { + + boolean asc = "ASC".equalsIgnoreCase(sortOrder); + + return items.stream() + .sorted((a, b) -> { + int cmp = switch (sortField) { + case "grossAmount" -> Integer.compare(a.grossAmount, b.grossAmount); + case "commission" -> Integer.compare(a.grossAmount / 10, b.grossAmount / 10); + case "netAmount" -> Integer.compare(a.grossAmount * 9 / 10, b.grossAmount * 9 / 10); + default -> a.orderDate.compareTo(b.orderDate); + }; + return asc ? cmp : -cmp; + }) + .toList(); } /** - * Settlement 엔티티를 DTO로 변환 + * Order 기반으로 Settlement DTO 변환 */ - private ArtistSettlementResponse.Settlement convertToSettlementDto( - com.back.domain.payment.settlement.entity.Settlement settlement) { + private ArtistSettlementResponse.Settlement convertToSettlementDtoFromOrder( + SettlementOrderItem item) { - // 상품 정보 (더미 - 실제로는 Settlement에 상품 정보가 없음) + // 상품 정보 ArtistSettlementResponse.Product product = new ArtistSettlementResponse.Product( - null, - "생활꿀팁미니 상품결제입니다" + item.productId, + item.productName ); // 날짜 포맷팅 - String dateStr = settlement.getCompletedAt() != null - ? settlement.getCompletedAt().format(DateTimeFormatter.ofPattern("yyyy. MM. dd")) - : settlement.getCreateDate().format(DateTimeFormatter.ofPattern("yyyy. MM. dd")); + String dateStr = item.orderDate.format(DateTimeFormatter.ofPattern("yyyy. MM. dd")); - // 상태 텍스트 (항상 정산완료 - 즉시 완료 처리되므로) + // 금액 계산 + int grossAmount = item.grossAmount; + int commission = grossAmount / 10; // 10% 수수료 + int netAmount = grossAmount - commission; + + // 배송 완료된 주문이므로 항상 정산완료 상태 String statusText = "정산완료"; return new ArtistSettlementResponse.Settlement( - settlement.getId(), + item.orderId, dateStr, product, - settlement.getRequestedAmount(), - settlement.getCommissionAmount(), - settlement.getNetAmount(), - settlement.getStatus().name(), + grossAmount, + commission, + netAmount, + "COMPLETED", statusText ); } + /** + * 정산용 주문 아이템 임시 클래스 + */ + private record SettlementOrderItem( + Long orderId, + LocalDateTime orderDate, + Long productId, + String productName, + int grossAmount + ) {} + + @Override public ArtistTrafficSourceResponse getTrafficSources(Long artistId, int days, String timezone) { log.info("작가 유입 경로 조회 - artistId: {}, days: {}, timezone: {}", artistId, days, timezone); diff --git a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java index e616ab84..e387a0ce 100644 --- a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java +++ b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java @@ -281,4 +281,22 @@ java.math.BigDecimal findTotalSettlementAmount( * 사용자별 주문 개수 조회 */ long countByUser(User user); + + /** + * 작가별 배송 완료된 주문 조회 (정산 통계용) + * - 특정 기간 내 DELIVERED 상태 주문만 + * - 작가가 판매한 상품의 주문만 + */ + @Query("SELECT DISTINCT o FROM Order o " + + "JOIN o.orderItems oi " + + "JOIN oi.product p " + + "WHERE p.user = :artist " + + "AND o.status = com.back.domain.order.order.entity.OrderStatus.DELIVERED " + + "AND o.orderDate >= :startDate " + + "AND o.orderDate <= :endDate") + List findDeliveredOrdersByArtistInPeriod( + @Param("artist") User artist, + @Param("startDate") java.time.LocalDateTime startDate, + @Param("endDate") java.time.LocalDateTime endDate + ); } diff --git a/src/test/java/com/back/domain/dashboard/artist/service/ArtistSettlementServiceTest.java b/src/test/java/com/back/domain/dashboard/artist/service/ArtistSettlementServiceTest.java index 16075cc7..7930c79d 100644 --- a/src/test/java/com/back/domain/dashboard/artist/service/ArtistSettlementServiceTest.java +++ b/src/test/java/com/back/domain/dashboard/artist/service/ArtistSettlementServiceTest.java @@ -1,11 +1,23 @@ package com.back.domain.dashboard.artist.service; +import com.back.domain.artist.entity.ArtistApplication; +import com.back.domain.artist.entity.ArtistProfile; +import com.back.domain.artist.repository.ArtistApplicationRepository; +import com.back.domain.artist.repository.ArtistProfileRepository; import com.back.domain.dashboard.artist.dto.request.ArtistSettlementSearchRequest; import com.back.domain.dashboard.artist.dto.response.ArtistSettlementResponse; -import com.back.domain.payment.moriCash.entity.MoriCashBalance; -import com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository; -import com.back.domain.payment.settlement.entity.Settlement; -import com.back.domain.payment.settlement.repository.SettlementRepository; +import com.back.domain.order.order.entity.Order; +import com.back.domain.order.order.entity.OrderStatus; +import com.back.domain.order.order.entity.PaymentMethod; +import com.back.domain.order.order.repository.OrderRepository; +import com.back.domain.order.orderItem.entity.OrderItem; +import com.back.domain.product.category.entity.Category; +import com.back.domain.product.category.repository.CategoryRepository; +import com.back.domain.product.product.entity.DeliveryType; +import com.back.domain.product.product.entity.DisplayStatus; +import com.back.domain.product.product.entity.Product; +import com.back.domain.product.product.entity.SellingStatus; +import com.back.domain.product.product.repository.ProductRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -15,13 +27,20 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +/** + * 작가 정산 내역 조회 테스트 + * Order 기반 정산 통계로 변경 후 테스트 + */ @SpringBootTest @Transactional +@DisplayName("작가 정산 내역 조회 테스트 (Order 기반)") class ArtistSettlementServiceTest { @Autowired @@ -31,13 +50,24 @@ class ArtistSettlementServiceTest { private UserRepository userRepository; @Autowired - private MoriCashBalanceRepository moriCashBalanceRepository; + private ProductRepository productRepository; @Autowired - private SettlementRepository settlementRepository; + private CategoryRepository categoryRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private ArtistProfileRepository artistProfileRepository; + + @Autowired + private ArtistApplicationRepository artistApplicationRepository; private User artist; - private MoriCashBalance balance; + private User customer; + private Category category; + private Product product; @BeforeEach void setUp() { @@ -46,93 +76,133 @@ void setUp() { artist.becomeArtist(); artist = userRepository.save(artist); - // 모리캐시 잔액 생성 및 충분한 금액 충전 - balance = MoriCashBalance.createInitialBalance(artist); - balance.addBalance(1000000); // 정산할 수 있도록 충분한 금액 충전 - balance = moriCashBalanceRepository.save(balance); - - // 정산 데이터 생성 - createSettlementData(); + // 작가 신청 및 프로필 생성 + ArtistApplication application = ArtistApplication.builder() + .user(artist) + .ownerName("작가") + .email("artist@test.com") + .phone("010-1234-5678") + .artistName("작가") + .businessNumber("123-45-67890") + .businessAddress("서울시") + .businessAddressDetail("강남구") + .businessZipCode("12345") + .telecomSalesNumber("2024-서울-0001") + .bankName("테스트은행") + .bankAccount("123-456-789012") + .accountName("작가") + .build(); + application = artistApplicationRepository.save(application); + + ArtistProfile profile = ArtistProfile.builder() + .user(artist) + .artistApplication(application) + .artistName("작가") + .bankName("테스트은행") + .bankAccount("123-456-789012") + .build(); + artistProfileRepository.save(profile); + + // 고객 생성 + customer = User.createLocalUser("customer@test.com", "password", "고객", "010-2222-2222"); + customer = userRepository.save(customer); + + // 카테고리 생성 + category = Category.builder() + .categoryName("테스트카테고리") + .build(); + category = categoryRepository.save(category); + + // 상품 생성 + product = Product.builder() + .category(category) + .user(artist) + .name("테스트 상품") + .brandName("테스트 브랜드") + .price(10000) + .discountRate(0) + .stock(100) + .bundleShippingAvailable(false) + .deliveryCharge(3000) + .additionalShippingCharge(0) + .deliveryType(DeliveryType.PAID) + .description("테스트 상품 설명") + .sellingStatus(SellingStatus.SELLING) + .displayStatus(DisplayStatus.DISPLAYING) + .minQuantity(1) + .maxQuantity(10) + .productModelName("TEST-001") + .certification(false) + .origin("한국") + .material("플라스틱") + .size("10x10cm") + .isPlanned(false) + .isRestock(false) + .isDeleted(false) + .build(); + product = productRepository.save(product); + + // 배송 완료된 주문 데이터 생성 + createDeliveredOrders(); } - private void createSettlementData() { - // 정산 데이터 5개 생성 (모두 현재 시간으로 생성됨) - for (int i = 0; i < 5; i++) { - int amount = 30000 + (i * 10000); // 30k, 40k, 50k, 60k, 70k - Settlement settlement = Settlement.builder() - .artist(artist) - .requestedAmount(amount) - .commissionRate(10) // 사용되지 않음 (환전 시 수수료 없음) - .bankName("데모은행") - .accountNumber("000-0000-0000") - .accountHolder(artist.getName()) + private void createDeliveredOrders() { + // 5개의 배송 완료된 주문 생성 + int[] amounts = {30000, 40000, 50000, 60000, 70000}; + + for (int amount : amounts) { + Order order = Order.builder() + .user(customer) + .orderNumber("ORD" + System.nanoTime()) + .status(OrderStatus.DELIVERED) // 배송 완료 상태 + .totalQuantity(amount / 10000) + .totalAmount(BigDecimal.valueOf(amount)) + .shippingFee(BigDecimal.valueOf(3000)) + .finalAmount(BigDecimal.valueOf(amount + 3000)) + .shippingAddress1("서울시") + .shippingAddress2("강남구") + .recipientName("수령인") + .recipientPhone("010-1234-5678") + .paymentMethod(PaymentMethod.CARD) + .orderDate(LocalDateTime.now()) .build(); - - settlement = settlementRepository.save(settlement); - - System.out.println("=== Created Settlement ==="); - System.out.println("ID: " + settlement.getId()); - System.out.println("Amount: " + settlement.getRequestedAmount()); - System.out.println("Commission: " + settlement.getCommissionAmount()); - System.out.println("Net: " + settlement.getNetAmount()); - System.out.println("Status: " + settlement.getStatus()); - System.out.println("CompletedAt: " + settlement.getCompletedAt()); - - // MoriCashBalance 통계 업데이트 (환전 처리) - balance.processSettlement(amount); + + OrderItem orderItem = OrderItem.builder() + .order(order) + .product(product) + .quantity(amount / 10000) + .price(BigDecimal.valueOf(10000)) + .build(); + + order.addOrderItem(orderItem); + orderRepository.save(order); } - - moriCashBalanceRepository.save(balance); - - // 저장된 데이터 확인 - List savedSettlements = settlementRepository.findAll(); - System.out.println("=== Total Settlements in DB: " + savedSettlements.size() + " ==="); } @Test - @DisplayName("정산 현황 조회 - 기본 조회") + @DisplayName("정산 현황 조회 - 기본 조회 (Order 기반)") void getSettlements_Basic() { // given ArtistSettlementSearchRequest request = new ArtistSettlementSearchRequest( LocalDate.now().getYear(), null, null, null, 0, 20, "date", "DESC" ); - System.out.println("=== Test Setup ==="); - System.out.println("Artist ID: " + artist.getId()); - System.out.println("Request Year: " + request.year()); - System.out.println("Current Year: " + LocalDate.now().getYear()); - - // 실제 저장된 데이터 확인 - List allSettlements = settlementRepository.findAll(); - System.out.println("Total settlements in DB: " + allSettlements.size()); - allSettlements.forEach(s -> { - System.out.println("Settlement: ID=" + s.getId() + - ", Artist=" + s.getArtist().getId() + - ", Amount=" + s.getRequestedAmount() + - ", Commission=" + s.getCommissionAmount() + - ", Net=" + s.getNetAmount() + - ", CompletedAt=" + s.getCompletedAt()); - }); - // when ArtistSettlementResponse response = artistDashboardService.getSettlements(artist.getId(), request); - System.out.println("=== Response ==="); - System.out.println("Summary - TotalSales: " + response.summary().totalSales().amount()); - System.out.println("Summary - Commission: " + response.summary().totalCommission().amount()); - System.out.println("Summary - NetIncome: " + response.summary().totalNetIncome().amount()); - System.out.println("Table - Content Size: " + response.table().getContent().size()); - System.out.println("Table - Total Elements: " + response.table().getTotalElements()); - // then assertThat(response).isNotNull(); assertThat(response.scope().year()).isEqualTo(LocalDate.now().getYear()); assertThat(response.scope().month()).isNull(); - // 요약 정보 확인 (환전 시 수수료 없음) - assertThat(response.summary().totalSales().amount()).isEqualTo(250000); // 30k + 40k + 50k + 60k + 70k - assertThat(response.summary().totalCommission().amount()).isEqualTo(0); // 환전 시 수수료 없음! - assertThat(response.summary().totalNetIncome().amount()).isEqualTo(250000); // 수수료 없으므로 총액과 동일 + // 요약 정보 확인 (배송 완료된 주문 기준 - 10% 수수료) + int expectedTotalSales = 250000; // 30k + 40k + 50k + 60k + 70k + int expectedCommission = 25000; // 10% + int expectedNetIncome = 225000; // 90% + + assertThat(response.summary().totalSales().amount()).isEqualTo(expectedTotalSales); + assertThat(response.summary().totalCommission().amount()).isEqualTo(expectedCommission); + assertThat(response.summary().totalNetIncome().amount()).isEqualTo(expectedNetIncome); // 테이블 데이터 확인 assertThat(response.table().getContent()).hasSize(5); @@ -156,7 +226,7 @@ void getSettlements_Month() { assertThat(response.scope().year()).isEqualTo(LocalDate.now().getYear()); assertThat(response.scope().month()).isEqualTo(currentMonth); - // 테이블 데이터 확인 (이번 달 데이터만) + // 테이블 데이터 확인 (이번 달 데이터) assertThat(response.table().getContent()).hasSize(5); assertThat(response.table().getContent()) .allMatch(s -> s.statusText().equals("정산완료")); @@ -259,12 +329,11 @@ void getSettlements_AllCompleted() { } @Test - @DisplayName("정산 현황 조회 - 수수료 계산 확인") + @DisplayName("정산 현황 조회 - 수수료 계산 확인 (Order 기반 - 10% 수수료)") void getSettlements_CommissionCalculation() { // given - int currentMonth = LocalDate.now().getMonthValue(); ArtistSettlementSearchRequest request = new ArtistSettlementSearchRequest( - LocalDate.now().getYear(), currentMonth, null, null, 0, 20, "date", "DESC" + LocalDate.now().getYear(), null, null, null, 0, 20, "date", "DESC" ); // when @@ -274,10 +343,13 @@ void getSettlements_CommissionCalculation() { List content = response.table().getContent(); assertThat(content).isNotEmpty(); - // 각 정산의 수수료와 순수익 확인 (환전 시 수수료 없음) + // 각 정산의 수수료와 순수익 확인 (판매 시 10% 수수료) content.forEach(settlement -> { - assertThat(settlement.commission()).isEqualTo(0); // 환전 시 수수료 없음 - assertThat(settlement.netAmount()).isEqualTo(settlement.grossAmount()); // 총액 = 순수익 + int expectedCommission = settlement.grossAmount() / 10; // 10% 수수료 + int expectedNetAmount = settlement.grossAmount() - expectedCommission; + + assertThat(settlement.commission()).isEqualTo(expectedCommission); + assertThat(settlement.netAmount()).isEqualTo(expectedNetAmount); }); } @@ -354,4 +426,22 @@ void getSettlements_ResponseStructure() { assertThat(response.timezone()).isEqualTo("Asia/Seoul"); assertThat(response.serverTime()).isNotNull(); } + + @Test + @DisplayName("정산 현황 조회 - 상품명 포함 확인") + void getSettlements_ProductName() { + // given + ArtistSettlementSearchRequest request = new ArtistSettlementSearchRequest( + LocalDate.now().getYear(), null, null, null, 0, 20, "date", "DESC" + ); + + // when + ArtistSettlementResponse response = artistDashboardService.getSettlements(artist.getId(), request); + + // then + List content = response.table().getContent(); + assertThat(content).isNotEmpty(); + assertThat(content).allMatch(s -> s.product() != null); + assertThat(content).allMatch(s -> s.product().name().equals("테스트 상품")); + } } From c8695906f54c8a8ebdbd086b8ef50c092fa3c951 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 14:09:20 +0900 Subject: [PATCH 03/31] =?UTF-8?q?refactor/336=20=EC=9E=91=EA=B0=80=20?= =?UTF-8?q?=EC=88=98=EC=9D=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OrderServiceArtistRevenueTest.java | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java index e08d9888..f488507c 100644 --- a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java +++ b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java @@ -27,8 +27,12 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * 주문 배송 완료 시 작가 수익 적립 테스트 + */ @SpringBootTest @Transactional +@DisplayName("작가 수익 적립 테스트") class OrderServiceArtistRevenueTest { @Autowired @@ -73,8 +77,9 @@ void setUp() { artist.becomeArtist(); artist = userRepository.save(artist); - // 관리자 생성 (테스트용) + // 관리자 생성 (ADMIN 권한) admin = User.createLocalUser("admin@test.com" + uniqueSuffix, "password", "관리자" + uniqueSuffix, "010-9999-9999"); + admin.becomeAdmin(); // ADMIN 권한 부여 admin = userRepository.save(admin); // 카테고리 생성 @@ -126,6 +131,10 @@ void setUp() { PaymentMethod.CARD ); order = orderRepository.save(order); + + // 영속성 컨텍스트 플러시 및 클리어 + entityManager.flush(); + entityManager.clear(); } @Test @@ -136,14 +145,11 @@ void creditArtistRevenue_OnDeliveryCompleted() { MoriCashBalance balanceBefore = moriCashBalanceRepository.findByUser(artist).orElse(null); int balanceBeforeAmount = balanceBefore != null ? balanceBefore.getAvailableBalance() : 0; - entityManager.flush(); - entityManager.clear(); - // when - 주문 상태를 DELIVERED로 변경 orderService.changeOrderStatus( order.getId(), new OrderStatusChangeRequestDto(OrderStatus.DELIVERED), - admin // 관리자가 상태 변경 + admin ); entityManager.flush(); @@ -223,7 +229,7 @@ void creditArtistRevenue_MultipleArtists() { orderService.changeOrderStatus( multiArtistOrder.getId(), new OrderStatusChangeRequestDto(OrderStatus.DELIVERED), - admin // 관리자가 상태 변경 + admin ); entityManager.flush(); @@ -248,14 +254,11 @@ void creditArtistRevenue_NotDelivered() { MoriCashBalance balanceBefore = moriCashBalanceRepository.findByUser(artist).orElse(null); int balanceBeforeAmount = balanceBefore != null ? balanceBefore.getAvailableBalance() : 0; - entityManager.flush(); - entityManager.clear(); - // when - SHIPPING으로만 변경 (DELIVERED 아님) orderService.changeOrderStatus( order.getId(), new OrderStatusChangeRequestDto(OrderStatus.SHIPPING), - admin // 관리자가 상태 변경 + admin ); entityManager.flush(); @@ -283,7 +286,7 @@ void creditArtistRevenue_ExistingBalance() { orderService.changeOrderStatus( order.getId(), new OrderStatusChangeRequestDto(OrderStatus.DELIVERED), - admin // 관리자가 상태 변경 + admin ); entityManager.flush(); @@ -295,6 +298,7 @@ void creditArtistRevenue_ExistingBalance() { // 기존 50,000원 + 신규 18,000원 = 68,000원 assertThat(balanceAfter.getAvailableBalance()).isEqualTo(68000); + assertThat(balanceAfter.getTotalBalance()).isEqualTo(68000); } @Test @@ -351,7 +355,7 @@ void creditArtistRevenue_CommissionCalculation() { orderService.changeOrderStatus( expensiveOrder.getId(), new OrderStatusChangeRequestDto(OrderStatus.DELIVERED), - admin // 관리자가 상태 변경 + admin ); entityManager.flush(); @@ -365,4 +369,26 @@ void creditArtistRevenue_CommissionCalculation() { int expectedRevenue = 99999 - (99999 / 10); assertThat(balance.getAvailableBalance()).isEqualTo(expectedRevenue); } + + @Test + @DisplayName("작가 수익 적립 메서드 직접 호출 테스트") + void creditArtistRevenue_DirectCall() { + // given + Order freshOrder = orderRepository.findByIdWithOrderItems(order.getId()) + .orElseThrow(() -> new AssertionError("주문을 찾을 수 없습니다.")); + + // when + orderService.creditArtistRevenue(freshOrder); + + entityManager.flush(); + entityManager.clear(); + + // then + MoriCashBalance balance = moriCashBalanceRepository.findByUser(artist) + .orElseThrow(() -> new AssertionError("작가의 모리캐시가 생성되지 않았습니다.")); + + // 20,000원 - 2,000원 = 18,000원 + assertThat(balance.getAvailableBalance()).isEqualTo(18000); + assertThat(balance.getTotalBalance()).isEqualTo(18000); + } } From ce4504d75ac943919b5a9999623550de336dc7d4 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 14:30:19 +0900 Subject: [PATCH 04/31] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EB=A9=94=EC=9D=B8=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9A=B0=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 87 +++++++++++++++---- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index 4c4cf679..04b56043 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -2,6 +2,8 @@ import com.back.domain.dashboard.artist.dto.request.*; import com.back.domain.dashboard.artist.dto.response.*; +import com.back.domain.follow.entity.Follow; +import com.back.domain.follow.repository.FollowRepository; import com.back.domain.funding.entity.Funding; import com.back.domain.funding.entity.FundingStatus; import com.back.domain.funding.repository.FundingContributionRepository; @@ -52,6 +54,7 @@ public class ArtistDashboardServiceImpl implements ArtistDashboardService { private final com.back.domain.payment.settlement.repository.SettlementRepository settlementRepository; private final com.back.domain.user.repository.UserRepository userRepository; private final com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; + private final FollowRepository followRepository; // ✅ 추가: 팔로우 기능 @Value("${google.analytics.property-id}") private String propertyId; @@ -83,9 +86,8 @@ public ArtistMainResponse getMainStats(Long artistId, ArtistMainStatsRequest req com.back.domain.dashboard.artist.dto.DashboardStatsDto stats = orderRepository.getArtistDashboardStats(artistId, startOfDay, endOfDay); - // TODO: 팔로우 기능 구현 시 수정 필요 - // 팔로워 수 조회 부분입니다. - int followerCount = artistProfile.getFollowerCount(); + // 팔로워 수 조회 (Follow 테이블에서 실시간 COUNT) + int followerCount = (int) followRepository.countByFollowingArtistId(artistProfile.getId()); // 오늘의 매출 조회 부분입니다. int todaysSales = stats.todaysSales().intValue(); @@ -265,12 +267,11 @@ private ArtistMainResponse.Trends createTrendsData(Long artistId, String range, totalOrders ); - // TODO: 팔로우 기능 구현 시 수정 필요 - // 6. 팔로워 시계열 데이터 (빈 데이터) - ArtistMainResponse.SeriesData followerSeries = new ArtistMainResponse.SeriesData( - "명", - List.of(), - 0 + // 6. 팔로워 시계열 데이터 (Follow 엔티티에서 직접 집계) + ArtistMainResponse.SeriesData followerSeries = createFollowerSeriesFromFollowEntities( + artistId, + startDate.toLocalDate(), + endDate.toLocalDate() ); ArtistMainResponse.Series series = new ArtistMainResponse.Series( @@ -290,7 +291,14 @@ private ArtistMainResponse.Trends createTrendsData(Long artistId, String range, ArtistMainResponse.ChangeData salesChange = calculateChange(totalSales, compareSales); ArtistMainResponse.ChangeData orderChange = calculateChange(totalOrders, compareOrders); - ArtistMainResponse.ChangeData followerChange = new ArtistMainResponse.ChangeData(0, 0.0); + + // 팔로워 변화량 계산 + int currentFollowerCount = (int) followRepository.countByFollowingArtistId(artistId); + List allFollows = followRepository.findFollowersByArtistId(artistId); + int compareFollowerCount = (int) allFollows.stream() + .filter(f -> !f.getCreateDate().isAfter(compareEndDate)) + .count(); + ArtistMainResponse.ChangeData followerChange = calculateChange(currentFollowerCount, compareFollowerCount); ArtistMainResponse.Changes changes = new ArtistMainResponse.Changes( salesChange, @@ -450,7 +458,7 @@ public ArtistCashResponse.Balance getCashBalance(Long artistId) { .orElseThrow(() -> new com.back.global.exception.ServiceException("404", "작가를 찾을 수 없습니다.")); // 2. MoriCashBalanceService를 통해 모리캐시 잔액 조회 - com.back.domain.payment.moriCash.dto.response.MoriCashBalanceResponseDto balanceDto = + com.back.domain.payment.moriCash.dto.response.MoriCashBalanceResponseDto balanceDto = moriCashBalanceService.getBalance(artist); // 3. 응답 DTO 변환 @@ -646,7 +654,7 @@ private String convertPaymentMethodToKorean(String paymentMethod) { if (paymentMethod == null) { return "기타"; } - + // 입금 수단: 모리캐시 (정산금이 모리캐시로 입금됨) // 환전 수단: 계좌이체 (모리캐시를 실제 계좌로 환전) return switch (paymentMethod.toUpperCase()) { @@ -1535,7 +1543,7 @@ public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSe } // 4. 요약 정보 조회 (배송 완료된 주문에서 계산) - List deliveredOrders = + List deliveredOrders = orderRepository.findDeliveredOrdersByArtistInPeriod(artist, startDate, endDate); // 작가의 상품 매출만 계산 @@ -1544,7 +1552,7 @@ public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSe .filter(item -> item.getProduct().getUser().getId().equals(artistId)) .mapToInt(item -> item.getPrice().intValue() * item.getQuantity()) .sum(); - + int totalCommission = totalSales / 10; // 10% 수수료 int totalNetIncome = totalSales - totalCommission; @@ -1633,7 +1641,7 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( Long artistId) { // 1. 배송 완료된 주문 조회 (정렬 없이 전체 조회) - List allOrders = + List allOrders = orderRepository.findDeliveredOrdersByArtistInPeriod(artist, startDate, endDate); // 2. 주문을 OrderItem 단위로 변환 (작가의 상품만) @@ -1655,7 +1663,7 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( // 4. 페이징 처리 int start = request.page() * request.size(); int end = Math.min(start + request.size(), settlementItems.size()); - List pagedItems = start < settlementItems.size() + List pagedItems = start < settlementItems.size() ? settlementItems.subList(start, end) : List.of(); @@ -1685,9 +1693,9 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( */ private List sortSettlementItems( List items, String sortField, String sortOrder) { - + boolean asc = "ASC".equalsIgnoreCase(sortOrder); - + return items.stream() .sorted((a, b) -> { int cmp = switch (sortField) { @@ -1745,7 +1753,8 @@ private record SettlementOrderItem( Long productId, String productName, int grossAmount - ) {} + ) { + } @Override @@ -1931,4 +1940,44 @@ public ArtistTrafficSourceResponse getTrafficSources(Long artistId, int days, St ); } } + + /** + * Follow 엔티티에서 직접 일별 팔로워 수 계산 + * - 기존 findFollowersByArtistId() 메서드만 사용 + * - 다른 팀원 코드 수정 불필요 + *

+ * ⚠️ 주의: 모든 Follow를 메모리에 로드하므로 + * 팔로워가 매우 많을 경우 성능 이슈 가능 (추후 최적화 권장) + */ + private ArtistMainResponse.SeriesData createFollowerSeriesFromFollowEntities( + Long artistId, LocalDate startDate, LocalDate endDate) { + + // 해당 작가의 모든 팔로우 관계 조회 (기존 메서드 활용) + List allFollows = followRepository.findFollowersByArtistId(artistId); + + List points = new ArrayList<>(); + + // 일별로 루프 + LocalDate current = startDate; + while (!current.isAfter(endDate)) { + final LocalDateTime endOfDay = current.atTime(23, 59, 59); + + // 해당 날짜까지 생성된 팔로우의 개수 + long count = allFollows.stream() + .filter(f -> !f.getCreateDate().isAfter(endOfDay)) + .count(); + + points.add(new ArtistMainResponse.DataPoint( + current.toString(), + (int) count + )); + + current = current.plusDays(1); + } + + // 현재 팔로워 수 (기존 메서드 사용) + int currentCount = (int) followRepository.countByFollowingArtistId(artistId); + + return new ArtistMainResponse.SeriesData("명", points, currentCount); + } } \ No newline at end of file From f436577be785e6e31044c685b326e153008b254d Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 15:17:23 +0900 Subject: [PATCH 05/31] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EB=A9=94=EC=9D=B8=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9A=B0=EC=88=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 30 +++--- .../ArtistDashboardServiceImplTest.java | 96 +++++++++++++++++-- 2 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index 04b56043..ffeb75ad 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -89,11 +89,11 @@ public ArtistMainResponse getMainStats(Long artistId, ArtistMainStatsRequest req // 팔로워 수 조회 (Follow 테이블에서 실시간 COUNT) int followerCount = (int) followRepository.countByFollowingArtistId(artistProfile.getId()); - // 오늘의 매출 조회 부분입니다. - int todaysSales = stats.todaysSales().intValue(); + // 오늘의 매출 조회 부분입니다. (null 처리) + int todaysSales = stats.todaysSales() != null ? stats.todaysSales().intValue() : 0; - // 오늘의 주문 수 조회 부분입니다. - int todaysOrders = stats.todaysOrderCount().intValue(); + // 오늘의 주문 수 조회 부분입니다. (null 처리) + int todaysOrders = stats.todaysOrderCount() != null ? stats.todaysOrderCount().intValue() : 0; // 상품 수 조회 부분입니다. int productCount = artistProfile.getProductCount(); @@ -106,14 +106,14 @@ public ArtistMainResponse getMainStats(Long artistId, ArtistMainStatsRequest req productCount, todaysSales, todaysOrders, - stats.totalSales().intValue(), - stats.totalOrderCount().intValue(), + stats.totalSales() != null ? stats.totalSales().intValue() : 0, + stats.totalOrderCount() != null ? stats.totalOrderCount().intValue() : 0, 0.0, // TODO: 평균 평점 (리뷰 기능 구현 시) pendingOrders ); - // 3. 트렌드 정보 - ArtistMainResponse.Trends trends = createTrendsData(artistId, request.range(), request.tz()); + // 3. 트렌드 정보 (Artist Profile ID도 함께 전달) + ArtistMainResponse.Trends trends = createTrendsData(artistId, artistProfile.getId(), request.range(), request.tz()); // 4. 알림 정보 (빈 데이터 - 다음 단계에서 구현) ArtistMainResponse.Notifications notifications = new ArtistMainResponse.Notifications( @@ -159,7 +159,7 @@ private int calculatePendingOrders(Long artistId) { /** * 트렌드 데이터 생성 (매출 + 주문 수) */ - private ArtistMainResponse.Trends createTrendsData(Long artistId, String range, String timezone) { + private ArtistMainResponse.Trends createTrendsData(Long artistId, Long artistProfileId, String range, String timezone) { // 1. 기간 계산 LocalDateTime endDate = LocalDateTime.now(); LocalDateTime startDate; @@ -269,7 +269,7 @@ private ArtistMainResponse.Trends createTrendsData(Long artistId, String range, // 6. 팔로워 시계열 데이터 (Follow 엔티티에서 직접 집계) ArtistMainResponse.SeriesData followerSeries = createFollowerSeriesFromFollowEntities( - artistId, + artistProfileId, startDate.toLocalDate(), endDate.toLocalDate() ); @@ -293,8 +293,8 @@ private ArtistMainResponse.Trends createTrendsData(Long artistId, String range, ArtistMainResponse.ChangeData orderChange = calculateChange(totalOrders, compareOrders); // 팔로워 변화량 계산 - int currentFollowerCount = (int) followRepository.countByFollowingArtistId(artistId); - List allFollows = followRepository.findFollowersByArtistId(artistId); + int currentFollowerCount = (int) followRepository.countByFollowingArtistId(artistProfileId); + List allFollows = followRepository.findFollowersByArtistId(artistProfileId); int compareFollowerCount = (int) allFollows.stream() .filter(f -> !f.getCreateDate().isAfter(compareEndDate)) .count(); @@ -1950,10 +1950,10 @@ public ArtistTrafficSourceResponse getTrafficSources(Long artistId, int days, St * 팔로워가 매우 많을 경우 성능 이슈 가능 (추후 최적화 권장) */ private ArtistMainResponse.SeriesData createFollowerSeriesFromFollowEntities( - Long artistId, LocalDate startDate, LocalDate endDate) { + Long artistProfileId, LocalDate startDate, LocalDate endDate) { // 해당 작가의 모든 팔로우 관계 조회 (기존 메서드 활용) - List allFollows = followRepository.findFollowersByArtistId(artistId); + List allFollows = followRepository.findFollowersByArtistId(artistProfileId); List points = new ArrayList<>(); @@ -1976,7 +1976,7 @@ private ArtistMainResponse.SeriesData createFollowerSeriesFromFollowEntities( } // 현재 팔로워 수 (기존 메서드 사용) - int currentCount = (int) followRepository.countByFollowingArtistId(artistId); + int currentCount = (int) followRepository.countByFollowingArtistId(artistProfileId); return new ArtistMainResponse.SeriesData("명", points, currentCount); } diff --git a/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java index 1f0dd530..e5de4aeb 100644 --- a/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImplTest.java @@ -87,6 +87,12 @@ class ArtistDashboardServiceImplTest { @Autowired private com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; + @Autowired + private com.back.domain.follow.repository.FollowRepository followRepository; // ✅ 추가 + + @Autowired + private jakarta.persistence.EntityManager entityManager; // ✅ EntityManager 추가 + private User testArtist; private User testCustomer; private Category testCategory; @@ -292,7 +298,7 @@ void getFundings_ReturnsRealData() { @DisplayName("입금/환전 내역 조회 - 실제 DB 데이터 검증") void getCashHistory_ReturnsRealData() { // Given - 입금 거래 생성 - com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction depositTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) @@ -305,7 +311,7 @@ void getCashHistory_ReturnsRealData() { cashTransactionRepository.save(depositTx); // Given - 환전 거래 생성 - com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) @@ -337,7 +343,7 @@ void getCashHistory_ReturnsRealData() { @DisplayName("입금/환전 내역 조회 - 거래 유형 필터링 (입금)") void getCashHistory_FiltersByDeposit() { // Given - com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction depositTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) @@ -348,7 +354,7 @@ void getCashHistory_FiltersByDeposit() { depositTx.completeTransaction("TX003", "APPROVAL003", 20000); cashTransactionRepository.save(depositTx); - com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) @@ -380,7 +386,7 @@ void getCashHistory_FiltersByDeposit() { @DisplayName("입금/환전 내역 조회 - 거래 유형 필터링 (환전)") void getCashHistory_FiltersByWithdrawal() { // Given - com.back.domain.payment.cash.entity.CashTransaction depositTx = + com.back.domain.payment.cash.entity.CashTransaction depositTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) @@ -391,7 +397,7 @@ void getCashHistory_FiltersByWithdrawal() { depositTx.completeTransaction("TX005", "APPROVAL005", 30000); cashTransactionRepository.save(depositTx); - com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = + com.back.domain.payment.cash.entity.CashTransaction withdrawalTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) @@ -423,7 +429,7 @@ void getCashHistory_FiltersByWithdrawal() { @DisplayName("입금/환전 내역 조회 - 상태 필터링") void getCashHistory_FiltersByStatus() { // Given - 완료된 거래 - com.back.domain.payment.cash.entity.CashTransaction completedTx = + com.back.domain.payment.cash.entity.CashTransaction completedTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) @@ -435,7 +441,7 @@ void getCashHistory_FiltersByStatus() { cashTransactionRepository.save(completedTx); // Given - 대기 중인 거래 - com.back.domain.payment.cash.entity.CashTransaction pendingTx = + com.back.domain.payment.cash.entity.CashTransaction pendingTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.EXCHANGE) @@ -462,7 +468,7 @@ void getCashHistory_FiltersByStatus() { @DisplayName("입금/환전 내역 조회 - 날짜 범위 필터링") void getCashHistory_FiltersByDateRange() { // Given - 오늘 거래 - com.back.domain.payment.cash.entity.CashTransaction todayTx = + com.back.domain.payment.cash.entity.CashTransaction todayTx = com.back.domain.payment.cash.entity.CashTransaction.builder() .user(testArtist) .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) @@ -584,7 +590,7 @@ void getCancellationRequests_ReturnsRealData() { .quantity(1) .refundPrice(BigDecimal.valueOf(10000)) .build(); - + // Refund의 refundItems 리스트에 추가 (양방향 연관관계) refund.getRefundItems().add(refundItem); refundRepository.save(refund); @@ -715,6 +721,63 @@ void getMainStats_ReturnsRealData() { ); } + @Test + @DisplayName("메인 대시보드 - 팔로워 기능 통합 검증") + void getMainStats_FollowerIntegration() { + // Given - 팔로워 3명 추가 (각각 고유한 전화번호 사용) + User follower1 = createTestFollower("follower1@test.com", "팔로워1", "010-1111-0001"); + User follower2 = createTestFollower("follower2@test.com", "팔로워2", "010-1111-0002"); + User follower3 = createTestFollower("follower3@test.com", "팔로워3", "010-1111-0003"); + + ArtistProfile artistProfile = artistProfileRepository.findByUserId(testArtist.getId()) + .orElseThrow(); + + com.back.domain.follow.entity.Follow follow1 = + com.back.domain.follow.entity.Follow.create(follower1, artistProfile); + com.back.domain.follow.entity.Follow follow2 = + com.back.domain.follow.entity.Follow.create(follower2, artistProfile); + com.back.domain.follow.entity.Follow follow3 = + com.back.domain.follow.entity.Follow.create(follower3, artistProfile); + + followRepository.save(follow1); + followRepository.save(follow2); + followRepository.save(follow3); + + // ✅ EntityManager를 통해 영속성 컨텍스트 플러시 및 클리어 + // 이렇게 하면 JPA Auditing이 확실하게 작동하여 createDate가 설정됩니다 + entityManager.flush(); + entityManager.clear(); + + ArtistMainStatsRequest request = new ArtistMainStatsRequest( + "30D", null, null, null, "Asia/Seoul"); + + // When + ArtistMainResponse result = artistDashboardService.getMainStats(testArtist.getId(), request); + + // Then + assertAll( + // 1. 실시간 팔로워 수 검증 + () -> assertThat(result.stats().followerCount()) + .as("팔로워 수는 Follow 테이블에서 실시간 COUNT") + .isEqualTo(3), + + // 2. 팔로워 그래프 데이터 검증 + () -> assertThat(result.trends().series().followers()) + .as("팔로워 그래프 데이터가 생성되어야 함") + .isNotNull(), + () -> assertThat(result.trends().series().followers().unit()).isEqualTo("명"), + () -> assertThat(result.trends().series().followers().total()).isEqualTo(3), + () -> assertThat(result.trends().series().followers().points()).isNotEmpty(), + + // 3. 팔로워 증감 계산 검증 + () -> assertThat(result.trends().changes().followers()) + .as("팔로워 증감량이 계산되어야 함") + .isNotNull(), + () -> assertThat(result.trends().changes().followers().delta()) + .isGreaterThanOrEqualTo(0) + ); + } + // ==================== 헬퍼 메서드 ==================== private Order createTestOrder(OrderStatus status) { @@ -744,4 +807,17 @@ private Order createTestOrder(OrderStatus status) { order.addOrderItem(orderItem); return orderRepository.save(order); } + + /** + * 테스트용 팔로워 사용자 생성 + */ + private User createTestFollower(String email, String name, String phone) { + User follower = User.createLocalUser( + email, + "password", + name, + phone + ); + return userRepository.save(follower); + } } From 4d13af00f146acd848a058dabfe44aeebf329219 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 15:35:16 +0900 Subject: [PATCH 06/31] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20=EC=9E=91?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/FollowingResponse.java | 86 +++---------------- .../service/DashboardServiceImpl.java | 72 +++++++++------- 2 files changed, 54 insertions(+), 104 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/dto/response/FollowingResponse.java b/src/main/java/com/back/domain/dashboard/customer/dto/response/FollowingResponse.java index cfb27288..a615e781 100644 --- a/src/main/java/com/back/domain/dashboard/customer/dto/response/FollowingResponse.java +++ b/src/main/java/com/back/domain/dashboard/customer/dto/response/FollowingResponse.java @@ -2,68 +2,31 @@ import com.back.global.util.PageResponse; -import java.time.LocalDateTime; - /** * 팔로우 관련 응답 DTO - * + *

* 사용자가 팔로우한 작가들의 정보를 포함 * 2025.09.22 수정 - API 명세 변경에 따른 구조 개편 + * 2025.10.14 수정 - Figma 디자인에 맞춰 불필요한 필드 제거 */ public class FollowingResponse { - + /** * 팔로우한 작가 목록 응답 */ public static class List extends PageResponse { - /** 조회 대상 사용자 프로필 */ - private final Profile profile; - /** 팔로우 현황 요약 정보 */ - private final SummaryDto summary; - + public List() { super(); - this.profile = null; - this.summary = null; } - - public List(Profile profile, SummaryDto summary, java.util.List content, - int page, int size, long totalElements, int totalPages, - boolean hasNext, boolean hasPrevious) { + + public List(java.util.List content, + int page, int size, long totalElements, int totalPages, + boolean hasNext, boolean hasPrevious) { super(content, page, size, totalElements, totalPages, hasNext, hasPrevious); - this.profile = profile; - this.summary = summary; - } - - public Profile getProfile() { - return profile; - } - - public SummaryDto getSummary() { - return summary; } } - - /** - * 조회 대상 사용자 프로필 정보 - */ - public record Profile( - /** 사용자 ID */ - String userId, - /** 닉네임 */ - String nickname, - /** 프로필 이미지 URL */ - String profileImageUrl - ) {} - - /** - * 팔로우 현황 요약 정보 - */ - public record SummaryDto( - /** 전체 팔로우 작가 수 */ - int totalFollowing - ) {} - + /** * 작가 정보 */ @@ -72,33 +35,10 @@ public record Artist( String artistId, /** 작가명 */ String artistName, - /** 프로필 이미지 URL */ + /** 프로필 이미지 URL (null인 경우 프론트에서 기본 이미지 표시) */ String profileImageUrl, - /** 팔로워 수 */ - int followerCount, /** 작가 페이지 URL */ - String artistPageUrl, - /** 팔로우 관계 정보 */ - FollowRelation followRelation, - /** 배지 정보 */ - Badge badges - ) {} - - /** - * 팔로우 관계 정보 - */ - public record FollowRelation( - /** 관계 상태 (FOLLOWING) */ - String status, - /** 팔로우한 날짜 */ - LocalDateTime followedAt - ) {} - - /** - * 작가 배지 정보 - */ - public record Badge( - /** 인증 작가 여부 */ - Boolean verified - ) {} + String artistPageUrl + ) { + } } diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index b6a46d2e..0097fdf6 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -43,6 +43,7 @@ public class DashboardServiceImpl implements DashboardService { private final com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; private final com.back.domain.payment.moriCash.repository.MoriCashPaymentRepository moriCashPaymentRepository; private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository; + private final com.back.domain.follow.repository.FollowRepository followRepository; private static final DateTimeFormatter ORDER_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); private static final DateTimeFormatter FUNDING_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); @@ -448,42 +449,51 @@ private String mapOrderStatusText(com.back.domain.order.order.entity.OrderStatus @Override public FollowingResponse.List getFollowingArtists(Long userId, FollowingSearchRequest request) { - // TODO: 실제 데이터베이스 조회 로직 구현 log.debug("팔로우한 작가 목록 조회 - userId: {}, request: {}", userId, request); - // 사용자 조회 + // 1. 사용자 조회 User user = userRepository.findById(userId) .orElseThrow(() -> new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다.")); - FollowingResponse.Profile profile = new FollowingResponse.Profile( - user.getId().toString(), - user.getName(), - user.getProfileImageUrl()); - - FollowingResponse.SummaryDto summary = - new FollowingResponse.SummaryDto(5); - - List content = Arrays.asList( - new FollowingResponse.Artist( - "artist_001", "감성작가", - "https://cdn.example.com/artists/artist_001/profile.jpg", - 500, "/artists/artist_001", - new FollowingResponse.FollowRelation("FOLLOWING", LocalDateTime.now()), - new FollowingResponse.Badge(true) - ), - new FollowingResponse.Artist( - "artist_002", "캐릭터작가", - "https://cdn.example.com/artists/artist_002/profile.jpg", - 123, "/artists/artist_002", - new FollowingResponse.FollowRelation("FOLLOWING", LocalDateTime.now().minusDays(1)), - new FollowingResponse.Badge(false) - ) - ); + // 2. 팔로우 목록 조회 + List follows = + followRepository.findFollowingsByFollowerId(userId); + + // 3. 페이징 처리 + long total = follows.size(); + int start = request.page() * request.size(); + int end = Math.min(start + request.size(), follows.size()); + List pagedFollows = + follows.subList(start, Math.min(end, follows.size())); + + // 4. DTO 변환 + List content = pagedFollows.stream() + .map(this::convertToFollowingArtist) + .collect(Collectors.toList()); + + // 5. 페이징 정보 계산 + int totalPages = (int) Math.ceil((double) total / request.size()); + boolean hasNext = request.page() < totalPages - 1; + boolean hasPrevious = request.page() > 0; return new FollowingResponse.List( - profile, summary, content, + content, request.page(), request.size(), - 5, 1, false, false); + total, totalPages, hasNext, hasPrevious); + } + + /** + * Follow 엔티티를 Artist DTO로 변환 + */ + private FollowingResponse.Artist convertToFollowingArtist(com.back.domain.follow.entity.Follow follow) { + com.back.domain.artist.entity.ArtistProfile artist = follow.getFollowingArtist(); + + return new FollowingResponse.Artist( + artist.getId().toString(), + artist.getArtistName(), + artist.getProfileImageUrl(), // null인 경우 프론트에서 기본 이미지 처리 + "/artists/" + artist.getId() + ); } @Override @@ -719,8 +729,8 @@ public CashResponse.HistoryList getCashHistory(Long userId, CashHistorySearchReq tx.getBalanceAfter() != null ? tx.getBalanceAfter() : 0, "모리캐시", "COMPLETED", - tx.getOrder() != null ? - new CashResponse.Link("/orders/" + tx.getOrder().getOrderNumber()) : null + tx.getOrder() != null ? + new CashResponse.Link("/orders/" + tx.getOrder().getOrderNumber()) : null ))); // 4. 날짜순 정렬 (최신순) @@ -729,7 +739,7 @@ public CashResponse.HistoryList getCashHistory(Long userId, CashHistorySearchReq // 5. 페이징 처리 int start = request.page() * request.size(); int end = Math.min(start + request.size(), allTransactions.size()); - var pagedContent = start < allTransactions.size() ? + var pagedContent = start < allTransactions.size() ? allTransactions.subList(start, end) : List.of(); // 6. 통계 계산 From 3f4ccc9073c6e6db666c9fd44b37226cfa40753d Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 15:55:58 +0900 Subject: [PATCH 07/31] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20=EC=9E=91?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/FollowingSearchRequest.java | 27 +-- .../controller/DashboardControllerTest.java | 92 ++++++++++ .../service/DashboardServiceImplTest.java | 157 +++++++++++++++++- 3 files changed, 251 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/dto/request/FollowingSearchRequest.java b/src/main/java/com/back/domain/dashboard/customer/dto/request/FollowingSearchRequest.java index b177a6c1..1bef8db9 100644 --- a/src/main/java/com/back/domain/dashboard/customer/dto/request/FollowingSearchRequest.java +++ b/src/main/java/com/back/domain/dashboard/customer/dto/request/FollowingSearchRequest.java @@ -2,11 +2,11 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Pattern; /** * 팔로우한 작가 목록 검색 요청 DTO * 2025.09.25 생성 + * 2025.10.14 수정 - 팔로우 기능 실제 db로 연동 */ public record FollowingSearchRequest( /** 페이지 번호 (0부터 시작) */ @@ -16,34 +16,13 @@ public record FollowingSearchRequest( /** 페이지 크기 (1-100) */ @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") - Integer size, - - /** 검색 키워드 (작가ID/작가명) */ - String keyword, - - /** 관계 상태 (FOLLOWING 고정) */ - @Pattern(regexp = "^FOLLOWING$", - message = "status는 FOLLOWING이어야 합니다") - String status, - - /** 정렬 기준 */ - @Pattern(regexp = "^(followedAt|artistName|followerCount|lastPublishedAt)$", - message = "sort는 followedAt, artistName, followerCount, lastPublishedAt 중 하나여야 합니다") - String sort, - - /** 정렬 방향 */ - @Pattern(regexp = "^(ASC|DESC)$", - message = "order는 ASC 또는 DESC여야 합니다") - String order + Integer size ) { /** * 기본값이 적용된 생성자 */ public FollowingSearchRequest { if (page == null) page = 0; - if (size == null) size = 10; - if (status == null) status = "FOLLOWING"; - if (sort == null) sort = "followedAt"; - if (order == null) order = "DESC"; + if (size == null) size = 8; // Figma 디자인: 한 페이지당 8개 (4x2 그리드) } } diff --git a/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java b/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java index 9b1bb03b..e80bffc8 100644 --- a/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java +++ b/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java @@ -347,6 +347,77 @@ void getFundingParticipations_ExcludesMeta() throws Exception { .andExpect(jsonPath("$.data.content[0].meta").doesNotExist()); } + @Test + @DisplayName("팔로우한 작가 목록 조회 API - 성공") + void getFollowingArtists_Success() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List mockResponse = + createMockFollowingList(); + given(dashboardService.getFollowingArtists( + eq(TEST_USER_ID), any(FollowingSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/following-artists") + .param("page", "0") + .param("size", "8")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content[0].artistId").exists()) + .andExpect(jsonPath("$.data.content[0].artistName").exists()) + .andExpect(jsonPath("$.data.content[0].profileImageUrl").exists()) + .andExpect(jsonPath("$.data.content[0].artistPageUrl").exists()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(8)); + } + + @Test + @DisplayName("팔로우한 작가 목록 조회 API - 불필요한 필드 제외 검증") + void getFollowingArtists_ExcludesUnnecessaryFields() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List mockResponse = + createMockFollowingList(); + given(dashboardService.getFollowingArtists( + eq(TEST_USER_ID), any(FollowingSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/following-artists") + .param("page", "0") + .param("size", "8")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.profile").doesNotExist()) + .andExpect(jsonPath("$.data.summary").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].followerCount").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].followRelation").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].badges").doesNotExist()); + } + + @Test + @DisplayName("팔로우한 작가 목록 조회 API - 페이징 처리 검증") + void getFollowingArtists_HandlesPagination() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List mockResponse = + createMockFollowingList(); + given(dashboardService.getFollowingArtists( + eq(TEST_USER_ID), any(FollowingSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/following-artists") + .param("page", "0") + .param("size", "8")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalElements").exists()) + .andExpect(jsonPath("$.data.totalPages").exists()) + .andExpect(jsonPath("$.data.hasNext").exists()) + .andExpect(jsonPath("$.data.hasPrevious").exists()); + } + // =========================== 헬퍼 메서드들 =========================== private AccountResponse.Settings createMockAccountSettings() { @@ -511,6 +582,27 @@ private com.back.domain.dashboard.customer.dto.response.FundingResponse.List cre ); } + private com.back.domain.dashboard.customer.dto.response.FollowingResponse.List createMockFollowingList() { + List content = List.of( + new com.back.domain.dashboard.customer.dto.response.FollowingResponse.Artist( + "1", + "작가명입니다", + "https://cdn.example.com/artist1.jpg", + "/artists/1" + ), + new com.back.domain.dashboard.customer.dto.response.FollowingResponse.Artist( + "2", + "다른작가", + null, // 프로필 이미지 없음 (프론트에서 기본 이미지 처리) + "/artists/2" + ) + ); + + return new com.back.domain.dashboard.customer.dto.response.FollowingResponse.List( + content, 0, 8, 2, 1, false, false + ); + } + /** * @AuthenticationPrincipal을 위한 커스텀 ArgumentResolver */ diff --git a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java index 84f311d5..0f561992 100644 --- a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java @@ -30,7 +30,7 @@ /** * DashboardServiceImpl 테스트 * 핵심 비즈니스 로직과 데이터 일관성에 집중 - * 2025.10.10 수정 - 작가 신청 내역 조회 테스트 추가 + * 2025.10.14 수정 - 팔로우 작가 조회 테스트 추가 */ @SpringBootTest @ActiveProfiles("test") @@ -622,6 +622,161 @@ void getCashHistory_MapsPaymentMethod() { ); } + // ==================== Following 팔로우한 작가 목록 조회 테스트 ==================== + + @Test + @DisplayName("팔로우한 작가 목록 조회 - 실제 DB 연동 확인") + void getFollowingArtists_ReturnsRealData() { + // Given: 작가 프로필 생성 + com.back.domain.artist.entity.ArtistApplication application = + com.back.domain.artist.entity.ArtistApplication.builder() + .user(testArtist) + .ownerName("테스트작가") + .email("artist@example.com") + .phone("010-1234-5678") + .artistName("작가명입니다") + .businessNumber("123-45-67890") + .businessAddress("서울시") + .businessAddressDetail("강남구") + .businessZipCode("12345") + .telecomSalesNumber("2024-서울-0001") + .build(); + artistApplicationRepository.save(application); + + com.back.domain.artist.entity.ArtistProfile artistProfile = + com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); + artistProfile.updateProfile( + "https://cdn.example.com/artist.jpg", + "작가명입니다", + "@instagram", + "작가 소개", + null, null, null, null, null, null, null + ); + + com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository = + applicationContext.getBean(com.back.domain.artist.repository.ArtistProfileRepository.class); + artistProfile = artistProfileRepository.save(artistProfile); + + // Follow 생성 + com.back.domain.follow.repository.FollowRepository followRepository = + applicationContext.getBean(com.back.domain.follow.repository.FollowRepository.class); + + com.back.domain.follow.entity.Follow follow = + com.back.domain.follow.entity.Follow.create(testBuyer, artistProfile); + followRepository.save(follow); + + com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); + + // When + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = + dashboardService.getFollowingArtists(testBuyer.getId(), request); + + // Then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).artistName()).isEqualTo("작가명입니다"), + () -> assertThat(result.getContent().get(0).profileImageUrl()) + .isEqualTo("https://cdn.example.com/artist.jpg"), + () -> assertThat(result.getContent().get(0).artistPageUrl()).contains("/artists/"), + () -> assertThat(result.getTotalElements()).isEqualTo(1) + ); + } + + @Test + @DisplayName("팔로우한 작가 목록 조회 - 팔로우가 없는 경우") + void getFollowingArtists_ReturnsEmptyWhenNoFollows() { + // Given + com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); + + // When + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = + dashboardService.getFollowingArtists(testBuyer.getId(), request); + + // Then + assertAll( + () -> assertThat(result.getContent()).isEmpty(), + () -> assertThat(result.getTotalElements()).isEqualTo(0), + () -> assertThat(result.getTotalPages()).isEqualTo(0), + () -> assertThat(result.isHasNext()).isFalse() + ); + } + + @Test + @DisplayName("팔로우한 작가 목록 조회 - 자신이 팔로우한 작가만 조회") + void getFollowingArtists_ReturnsOnlyOwnFollows() { + // Given: 다른 사용자 생성 + User otherUser = User.createLocalUser( + "other@example.com", + "password", + "다른사용자", + "01099999999" + ); + otherUser = userRepository.save(otherUser); + + // 작가 프로필 생성 + com.back.domain.artist.entity.ArtistApplication application = + com.back.domain.artist.entity.ArtistApplication.builder() + .user(testArtist) + .ownerName("테스트작가") + .email("artist@example.com") + .phone("010-1234-5678") + .artistName("공통작가") + .businessNumber("123-45-67890") + .businessAddress("서울시") + .businessAddressDetail("강남구") + .businessZipCode("12345") + .telecomSalesNumber("2024-서울-0001") + .build(); + artistApplicationRepository.save(application); + + com.back.domain.artist.entity.ArtistProfile artistProfile = + com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); + + com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository = + applicationContext.getBean(com.back.domain.artist.repository.ArtistProfileRepository.class); + artistProfile = artistProfileRepository.save(artistProfile); + + // Follow 생성 + com.back.domain.follow.repository.FollowRepository followRepository = + applicationContext.getBean(com.back.domain.follow.repository.FollowRepository.class); + + // testBuyer가 팔로우 + com.back.domain.follow.entity.Follow follow1 = + com.back.domain.follow.entity.Follow.create(testBuyer, artistProfile); + followRepository.save(follow1); + + // otherUser도 같은 작가 팔로우 + com.back.domain.follow.entity.Follow follow2 = + com.back.domain.follow.entity.Follow.create(otherUser, artistProfile); + followRepository.save(follow2); + + com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); + + // When: testBuyer의 팔로우 목록 조회 + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = + dashboardService.getFollowingArtists(testBuyer.getId(), request); + + // Then: testBuyer의 팔로우만 조회되어야 함 + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getTotalElements()).isEqualTo(1) + ); + + // When: otherUser의 팔로우 목록 조회 + com.back.domain.dashboard.customer.dto.response.FollowingResponse.List otherResult = + dashboardService.getFollowingArtists(otherUser.getId(), request); + + // Then: otherUser의 팔로우도 1개여야 함 + assertAll( + () -> assertThat(otherResult.getContent()).hasSize(1), + () -> assertThat(otherResult.getTotalElements()).isEqualTo(1) + ); + } + // ==================== Helper Methods (캐시 테스트용) ==================== private com.back.domain.payment.cash.entity.CashTransaction createChargeTransaction( From 0b4d76d026de9285f84aaa138e64aa9ac0b7b513 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 17:50:15 +0900 Subject: [PATCH 08/31] =?UTF-8?q?refactor/336=20Response=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/WishlistResponse.java | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/dto/response/WishlistResponse.java b/src/main/java/com/back/domain/dashboard/customer/dto/response/WishlistResponse.java index 69ae4412..44807909 100644 --- a/src/main/java/com/back/domain/dashboard/customer/dto/response/WishlistResponse.java +++ b/src/main/java/com/back/domain/dashboard/customer/dto/response/WishlistResponse.java @@ -6,51 +6,56 @@ /** * 찜하기 관련 응답 DTO - *사용자가 찜한 상품들의 정보를 포함 - *2025.09.22 수정 + * 사용자가 찜한 상품들의 정보를 포함 + * 2025.09.22 수정 */ public class WishlistResponse { - + /** * 찜한 상품 목록 응답 */ public static class List extends PageResponse { - /** 찜하기 현황 요약 정보 */ + /** + * 찜하기 현황 요약 정보 + */ private final SummaryDto summary; - /** 일괄 작업 옵션 */ + /** + * 일괄 작업 옵션 + */ private final java.util.List bulkActions; - + public List() { super(); this.summary = null; this.bulkActions = null; } - - public List(SummaryDto summary, java.util.List bulkActions, - java.util.List content, int page, int size, - long totalElements, int totalPages, boolean hasNext, boolean hasPrevious) { + + public List(SummaryDto summary, java.util.List bulkActions, + java.util.List content, int page, int size, + long totalElements, int totalPages, boolean hasNext, boolean hasPrevious) { super(content, page, size, totalElements, totalPages, hasNext, hasPrevious); this.summary = summary; this.bulkActions = bulkActions; } - + public SummaryDto getSummary() { return summary; } - + public java.util.List getBulkActions() { return bulkActions; } } - + /** * 찜하기 현황 요약 정보 */ public record SummaryDto( /** 전체 찜한 상품 수 */ int totalWishItems - ) {} - + ) { + } + /** * 찜한 상품 정보 */ @@ -58,33 +63,36 @@ public record Item( String wishId, Long productId, String productNumber, + String brandName, String productName, int price, Artist artist, String imageUrl, String sellingStatus, - String registeredDate, LocalDateTime addedAt, String productPageUrl, Permission permissions - ) {} - + ) { + } + /** * 작가 정보 */ public record Artist( String id, String name - ) {} - + ) { + } + /** * 권한 정보 */ public record Permission( /** 찜 해제 가능 여부 */ Boolean canUnwish - ) {} - + ) { + } + /** * 일괄 작업 옵션 */ @@ -95,5 +103,6 @@ public record BulkAction( String label, /** 확인 필요 여부 */ Boolean requiresConfirmation - ) {} + ) { + } } From d96f4682b234072be886a7f19acbee455fcf9a63 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 17:50:33 +0900 Subject: [PATCH 09/31] =?UTF-8?q?refactor/354=EB=A6=AC=EB=B7=B0=20mock=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EC=8B=A4=EC=A0=9C=20db=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DashboardServiceImpl.java | 90 ++++++++++++++----- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index 0097fdf6..67cf569a 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -44,6 +44,7 @@ public class DashboardServiceImpl implements DashboardService { private final com.back.domain.payment.moriCash.repository.MoriCashPaymentRepository moriCashPaymentRepository; private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository; private final com.back.domain.follow.repository.FollowRepository followRepository; + private final com.back.domain.wishlist.repository.WishlistRepository wishlistRepository; private static final DateTimeFormatter ORDER_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); private static final DateTimeFormatter FUNDING_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); @@ -498,36 +499,83 @@ private FollowingResponse.Artist convertToFollowingArtist(com.back.domain.follow @Override public WishlistResponse.List getWishlist(Long userId, WishlistSearchRequest request) { - // TODO: 실제 데이터베이스 조회 로직 구현 log.debug("찜한 상품 목록 조회 - userId: {}, request: {}", userId, request); - WishlistResponse.SummaryDto summary = new WishlistResponse.SummaryDto(15); + // 1. 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다.")); + + // 2. 페이지 크기를 8개로 고정 (2줄 x 4개) + int pageSize = 8; + Pageable pageable = PageRequest.of(request.page(), pageSize); - List bulkActions = List.of( - new WishlistResponse.BulkAction("BULK_UNWISH", "선택 항목 해제", true) + // 3. 찜한 상품 목록 조회 + Page wishlistPage = + wishlistRepository.findWishlistsByUserIdForDashboard(userId, pageable); + + // 4. DTO 변환 + List content = wishlistPage.getContent().stream() + .map(this::convertToWishlistItem) + .collect(Collectors.toList()); + + // 5. 통계 정보 + WishlistResponse.SummaryDto summary = new WishlistResponse.SummaryDto( + (int) wishlistPage.getTotalElements() ); - List content = List.of( - new WishlistResponse.Item( - "w-001", 123157L, "0123157", "감성 일러스트 포스터", 25000, - new WishlistResponse.Artist("artist001", "감성작가"), - "https://cdn.example.com/p/123157/main.jpg", "SELLING", "2025-09-18", - LocalDateTime.now(), "/products/0123157", - new WishlistResponse.Permission(true) - ), - new WishlistResponse.Item( - "w-002", 123158L, "0123158", "귀여운 스티커 세트", 15000, - new WishlistResponse.Artist("artist002", "캐릭터작가"), - "https://cdn.example.com/p/123158/main.jpg", "SELLING", "2025-09-17", - LocalDateTime.now().minusDays(1), "/products/0123158", - new WishlistResponse.Permission(true) - ) + // 6. 일괄 작업 옵션 + List bulkActions = List.of( + new WishlistResponse.BulkAction("BULK_UNWISH", "선택 항목 해제", true) ); + // 7. 응답 생성 return new WishlistResponse.List( summary, bulkActions, content, - request.page(), request.size(), - 15, 2, true, false); + wishlistPage.getNumber(), pageSize, + wishlistPage.getTotalElements(), wishlistPage.getTotalPages(), + wishlistPage.hasNext(), wishlistPage.hasPrevious() + ); + } + + /** + * Wishlist 엔티티를 WishlistResponse.Item DTO로 변환 + */ + private WishlistResponse.Item convertToWishlistItem(com.back.domain.wishlist.entity.Wishlist wishlist) { + com.back.domain.product.product.entity.Product product = wishlist.getProduct(); + + // 상품 썸네일 이미지 조회 + String imageUrl = getProductThumbnailUrl(product); + + // 판매 상태 매핑 + String sellingStatus = product.getSellingStatus() != null ? + product.getSellingStatus().name() : "SELLING"; + + // 작가 정보 + WishlistResponse.Artist artist = new WishlistResponse.Artist( + product.getUser().getId().toString(), + product.getUser().getName() + ); + + // 권한 정보 (항상 찜 해제 가능) + WishlistResponse.Permission permissions = new WishlistResponse.Permission(true); + + // 상품 페이지 URL + String productPageUrl = "/products/" + product.getProductUuid(); + + return new WishlistResponse.Item( + wishlist.getId().toString(), + product.getId(), + product.getProductUuid().toString(), + product.getBrandName(), // 브랜드명 + product.getName(), // 상품명(글 제목) + product.getPrice(), // 할인율 제외, 정가만 전송 + artist, + imageUrl, + sellingStatus, + wishlist.getCreateDate(), + productPageUrl, + permissions + ); } @Override From bd948e0eb9533862e7873f506caf1735692dd191 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 17:51:14 +0900 Subject: [PATCH 10/31] =?UTF-8?q?refactor/354=20=EB=A0=88=ED=8C=8C?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=EC=97=90=20=EC=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/WishlistRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java index 4cdf18e0..52172b6e 100644 --- a/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java +++ b/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java @@ -1,14 +1,32 @@ package com.back.domain.wishlist.repository; import com.back.domain.wishlist.entity.Wishlist; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface WishlistRepository extends JpaRepository { // 찜 등록 여부 조회 boolean existsByUserIdAndProductId(Long userId, Long productId); + // 찜 삭제 void deleteByUserIdAndProductId(Long userId, Long productId); + // 상품별 찜 개수 조회 Long countByProductId(Long productId); + /** + * 대시보드용: 사용자의 찜한 상품 목록 조회 (페이징, Product와 User fetch join) + * Product의 images는 별도로 BatchSize로 처리됨 + */ + @Query("SELECT w FROM Wishlist w " + + "JOIN FETCH w.product p " + + "JOIN FETCH p.user " + + "WHERE w.user.id = :userId " + + "AND p.isDeleted = false " + + "ORDER BY w.createDate DESC") + Page findWishlistsByUserIdForDashboard(@Param("userId") Long userId, Pageable pageable); + } From 19b26b6aba56b7bbd2a72c87f63e09d154009018 Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 20:06:06 +0900 Subject: [PATCH 11/31] =?UTF-8?q?refactor/354=20=EC=B0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DashboardControllerTest.java | 137 +++++++ .../service/DashboardServiceImplTest.java | 368 +++++++++++++++++- .../OrderServiceArtistRevenueTest.java | 1 + 3 files changed, 486 insertions(+), 20 deletions(-) diff --git a/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java b/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java index e80bffc8..5f104f9a 100644 --- a/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java +++ b/src/test/java/com/back/domain/dashboard/customer/controller/DashboardControllerTest.java @@ -603,6 +603,143 @@ private com.back.domain.dashboard.customer.dto.response.FollowingResponse.List c ); } + @Test + @DisplayName("찜한 상품 목록 조회 API - 성공") + void getWishlist_Success() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List mockResponse = + createMockWishlistResponse(); + given(dashboardService.getWishlist( + eq(TEST_USER_ID), any(WishlistSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/wishlist") + .param("page", "0")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value("200")) + .andExpect(jsonPath("$.data.summary.totalWishItems").value(2)) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content[0].brandName").exists()) + .andExpect(jsonPath("$.data.content[0].productName").exists()) + .andExpect(jsonPath("$.data.content[0].price").exists()) + .andExpect(jsonPath("$.data.content[0].artist").exists()) + .andExpect(jsonPath("$.data.content[0].imageUrl").exists()) + .andExpect(jsonPath("$.data.size").value(8)); // 페이지 크기 8개 고정 + } + + @Test + @DisplayName("찜한 상품 목록 조회 API - 페이지 크기 8개 고정 확인") + void getWishlist_FixesPageSizeToEight() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List mockResponse = + createMockWishlistResponse(); + given(dashboardService.getWishlist( + eq(TEST_USER_ID), any(WishlistSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then: size를 다르게 요청해도 8개로 고정됨 + mockMvc.perform(get("/api/dashboard/wishlist") + .param("page", "0") + .param("size", "20")) // 20개 요청해도 + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size").value(8)); // 8개로 고정 + } + + @Test + @DisplayName("찜한 상품 목록 조회 API - brandName과 productName 모두 반환") + void getWishlist_ReturnsBothNames() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List mockResponse = + createMockWishlistResponse(); + given(dashboardService.getWishlist( + eq(TEST_USER_ID), any(WishlistSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/wishlist") + .param("page", "0")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].brandName").value("테스트 브랜드")) + .andExpect(jsonPath("$.data.content[0].productName").value("감성 일러스트 포스터")); + } + + @Test + @DisplayName("찜한 상품 목록 조회 API - 할인율 제외 확인") + void getWishlist_ExcludesDiscountRate() throws Exception { + // Given + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List mockResponse = + createMockWishlistResponse(); + given(dashboardService.getWishlist( + eq(TEST_USER_ID), any(WishlistSearchRequest.class))) + .willReturn(mockResponse); + + // When & Then + mockMvc.perform(get("/api/dashboard/wishlist") + .param("page", "0")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].price").value(25000)) + .andExpect(jsonPath("$.data.content[0].discountRate").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].discountPrice").doesNotExist()); + } + + private com.back.domain.dashboard.customer.dto.response.WishlistResponse.List createMockWishlistResponse() { + com.back.domain.dashboard.customer.dto.response.WishlistResponse.SummaryDto summary = + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.SummaryDto(2); + + java.util.List bulkActions = + java.util.List.of( + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.BulkAction( + "BULK_UNWISH", "선택 항목 해제", true + ) + ); + + java.util.List content = + java.util.List.of( + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Item( + "1", + 123L, + "uuid-123", + "테스트 브랜드", + "감성 일러스트 포스터", + 25000, + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Artist( + "1", "감성작가" + ), + "https://cdn.example.com/image.jpg", + "SELLING", + LocalDateTime.now(), + "/products/uuid-123", + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Permission(true) + ), + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Item( + "2", + 124L, + "uuid-124", + "귀여운 브랜드", + "귀여운 스티커 세트", + 15000, + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Artist( + "2", "캐릭터작가" + ), + "https://cdn.example.com/image2.jpg", + "SELLING", + LocalDateTime.now().minusDays(1), + "/products/uuid-124", + new com.back.domain.dashboard.customer.dto.response.WishlistResponse.Permission(true) + ) + ); + + return new com.back.domain.dashboard.customer.dto.response.WishlistResponse.List( + summary, bulkActions, content, + 0, 8, 2, 1, false, false + ); + } + /** * @AuthenticationPrincipal을 위한 커스텀 ArgumentResolver */ diff --git a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java index 0f561992..d30aaf55 100644 --- a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java @@ -600,9 +600,10 @@ void getCashHistory_MapsPaymentMethod() { com.back.domain.payment.moriCash.entity.MoriCashBalance.createInitialBalance(testBuyer); moriCashBalanceRepository.save(balance); - createChargeTransactionWithMethod(10000, "TOSS"); - createChargeTransactionWithMethod(20000, "NAVERPAY"); - createPurchaseTransaction(5000, LocalDateTime.now()); + // 명확한 시간 차이를 두어 정렬 순서 보장 + createChargeTransactionWithTimeAndMethod(10000, LocalDateTime.now().minusDays(3), "TOSS"); // 가장 오래된 + createChargeTransactionWithTimeAndMethod(20000, LocalDateTime.now().minusDays(2), "NAVERPAY"); // 중간 + createPurchaseTransaction(5000, LocalDateTime.now().minusDays(1)); // 가장 최근 com.back.domain.dashboard.customer.dto.request.CashHistorySearchRequest request = new com.back.domain.dashboard.customer.dto.request.CashHistorySearchRequest( @@ -613,12 +614,12 @@ void getCashHistory_MapsPaymentMethod() { com.back.domain.dashboard.customer.dto.response.CashResponse.HistoryList result = dashboardService.getCashHistory(testBuyer.getId(), request); - // Then + // Then: 최신순 정렬이므로 역순으로 검증 assertAll( () -> assertThat(result.getContent()).hasSize(3), - () -> assertThat(result.getContent().get(0).paymentMethod()).isEqualTo("모리캐시"), - () -> assertThat(result.getContent().get(1).paymentMethod()).isEqualTo("네이버페이"), - () -> assertThat(result.getContent().get(2).paymentMethod()).isEqualTo("토스페이") + () -> assertThat(result.getContent().get(0).paymentMethod()).isEqualTo("모리캐시"), // 가장 최근 + () -> assertThat(result.getContent().get(1).paymentMethod()).isEqualTo("네이버페이"), // 중간 + () -> assertThat(result.getContent().get(2).paymentMethod()).isEqualTo("토스페이") // 가장 오래된 ); } @@ -627,24 +628,35 @@ void getCashHistory_MapsPaymentMethod() { @Test @DisplayName("팔로우한 작가 목록 조회 - 실제 DB 연동 확인") void getFollowingArtists_ReturnsRealData() { + // Given: 고유한 작가 생성 (테스트 격리) + String uniqueSuffix = "_follow_real_" + System.nanoTime(); + User uniqueArtist = User.createLocalUser( + "unique-artist@example.com" + uniqueSuffix, + "password", + "고유작가" + uniqueSuffix, + "010-8888-8888" + ); + uniqueArtist.becomeArtist(); + uniqueArtist = userRepository.save(uniqueArtist); + // Given: 작가 프로필 생성 com.back.domain.artist.entity.ArtistApplication application = com.back.domain.artist.entity.ArtistApplication.builder() - .user(testArtist) + .user(uniqueArtist) .ownerName("테스트작가") - .email("artist@example.com") + .email("artist@example.com" + uniqueSuffix) .phone("010-1234-5678") .artistName("작가명입니다") - .businessNumber("123-45-67890") + .businessNumber("123-45-67890" + uniqueSuffix) .businessAddress("서울시") .businessAddressDetail("강남구") .businessZipCode("12345") - .telecomSalesNumber("2024-서울-0001") + .telecomSalesNumber("2024-서울-0001" + uniqueSuffix) .build(); artistApplicationRepository.save(application); com.back.domain.artist.entity.ArtistProfile artistProfile = - com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); + com.back.domain.artist.entity.ArtistProfile.fromApplication(uniqueArtist, application); artistProfile.updateProfile( "https://cdn.example.com/artist.jpg", "작가명입니다", @@ -707,33 +719,46 @@ void getFollowingArtists_ReturnsEmptyWhenNoFollows() { @Test @DisplayName("팔로우한 작가 목록 조회 - 자신이 팔로우한 작가만 조회") void getFollowingArtists_ReturnsOnlyOwnFollows() { - // Given: 다른 사용자 생성 + // Given: 고유한 데이터로 테스트 격리 + String uniqueSuffix = "_follow_own_" + System.nanoTime(); + + // 다른 사용자 생성 User otherUser = User.createLocalUser( - "other@example.com", + "other@example.com" + uniqueSuffix, "password", - "다른사용자", + "다른사용자" + uniqueSuffix, "01099999999" ); otherUser = userRepository.save(otherUser); + // 고유한 작가 생성 + User uniqueArtist = User.createLocalUser( + "unique-artist2@example.com" + uniqueSuffix, + "password", + "고유작가2" + uniqueSuffix, + "010-7777-7777" + ); + uniqueArtist.becomeArtist(); + uniqueArtist = userRepository.save(uniqueArtist); + // 작가 프로필 생성 com.back.domain.artist.entity.ArtistApplication application = com.back.domain.artist.entity.ArtistApplication.builder() - .user(testArtist) + .user(uniqueArtist) .ownerName("테스트작가") - .email("artist@example.com") + .email("artist@example.com" + uniqueSuffix) .phone("010-1234-5678") .artistName("공통작가") - .businessNumber("123-45-67890") + .businessNumber("123-45-67890" + uniqueSuffix) .businessAddress("서울시") .businessAddressDetail("강남구") .businessZipCode("12345") - .telecomSalesNumber("2024-서울-0001") + .telecomSalesNumber("2024-서울-0001" + uniqueSuffix) .build(); artistApplicationRepository.save(application); com.back.domain.artist.entity.ArtistProfile artistProfile = - com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); + com.back.domain.artist.entity.ArtistProfile.fromApplication(uniqueArtist, application); com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository = applicationContext.getBean(com.back.domain.artist.repository.ArtistProfileRepository.class); @@ -824,6 +849,33 @@ private com.back.domain.payment.cash.entity.CashTransaction createChargeTransact return cashTransactionRepository.save(transaction); } + private com.back.domain.payment.cash.entity.CashTransaction createChargeTransactionWithTimeAndMethod( + int amount, LocalDateTime completedAt, String method) { + com.back.domain.payment.cash.entity.CashTransaction transaction = + com.back.domain.payment.cash.entity.CashTransaction.builder() + .user(testBuyer) + .transactionType(com.back.domain.payment.cash.entity.CashTransactionType.CHARGING) + .amount(amount) + .paymentMethod(method) + .pgProvider("TOSS") + .balanceAfter(50000 + amount) + .build(); + + transaction.completeTransaction("PG-" + System.currentTimeMillis(), "APPR-123", + 50000 + amount); + + // completedAt을 테스트용으로 강제 설정 (Reflection 사용) + try { + var field = transaction.getClass().getDeclaredField("completedAt"); + field.setAccessible(true); + field.set(transaction, completedAt); + } catch (Exception e) { + // completedAt 설정 실패 시 현재 시간 사용 + } + + return cashTransactionRepository.save(transaction); + } + private com.back.domain.payment.moriCash.entity.MoriCashPayment createPurchaseTransaction( int amount, LocalDateTime paidAt) { com.back.domain.payment.moriCash.entity.MoriCashPayment payment = @@ -851,4 +903,280 @@ private com.back.domain.payment.moriCash.entity.MoriCashPayment createPurchaseTr return moriCashPaymentRepository.save(payment); } + // ==================== Wishlist 찜한 상품 목록 조회 테스트 ==================== + + @Test + @DisplayName("찜한 상품 목록 조회 - 실제 DB 연동 확인") + void getWishlist_ReturnsRealData() { + // Given: 찜하기 생성 + com.back.domain.wishlist.repository.WishlistRepository wishlistRepository = + applicationContext.getBean(com.back.domain.wishlist.repository.WishlistRepository.class); + + com.back.domain.wishlist.entity.Wishlist wishlist = + com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer) + .product(testProduct) + .build(); + wishlistRepository.save(wishlist); + + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 8, null, null, null, null, null + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).brandName()).isEqualTo("테스트 브랜드"), + () -> assertThat(result.getContent().get(0).productName()).isEqualTo("테스트 상품"), + () -> assertThat(result.getContent().get(0).price()).isEqualTo(10000), + () -> assertThat(result.getContent().get(0).artist().name()).isEqualTo("테스트작가"), + () -> assertThat(result.getSummary().totalWishItems()).isEqualTo(1), + () -> assertThat(result.getSize()).isEqualTo(8) // 페이지 크기 8개 고정 + ); + } + + @Test + @DisplayName("찜한 상품 목록 조회 - 페이지 크기 8개 고정 확인") + void getWishlist_FixesPageSizeToEight() { + // Given: 찜 10개 생성 + com.back.domain.wishlist.repository.WishlistRepository wishlistRepository = + applicationContext.getBean(com.back.domain.wishlist.repository.WishlistRepository.class); + + for (int i = 0; i < 10; i++) { + com.back.domain.product.product.entity.Product product = + createTestProduct("상품" + i, "브랜드" + i); + + com.back.domain.wishlist.entity.Wishlist wishlist = + com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer) + .product(product) + .build(); + wishlistRepository.save(wishlist); + } + + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 999, null, null, null, null, null // size를 999로 요청해도 + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then: 8개만 조회되어야 함 + assertAll( + () -> assertThat(result.getContent()).hasSize(8), + () -> assertThat(result.getSize()).isEqualTo(8), // 페이지 크기 8개 고정 + () -> assertThat(result.getTotalElements()).isEqualTo(10), + () -> assertThat(result.getTotalPages()).isEqualTo(2), + () -> assertThat(result.isHasNext()).isTrue() + ); + } + + @Test + @DisplayName("찜한 상품 목록 조회 - 삭제된 상품 제외") + void getWishlist_ExcludesDeletedProducts() { + // Given: 일반 상품과 삭제된 상품 찜하기 + com.back.domain.wishlist.repository.WishlistRepository wishlistRepository = + applicationContext.getBean(com.back.domain.wishlist.repository.WishlistRepository.class); + + // 일반 상품 찜 + com.back.domain.wishlist.entity.Wishlist wishlist1 = + com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer) + .product(testProduct) + .build(); + wishlistRepository.save(wishlist1); + + // 삭제된 상품 생성 및 찜 + com.back.domain.product.product.entity.Product deletedProduct = + createTestProduct("삭제된상품", "삭제된브랜드"); + deletedProduct.setDeleted(true); + productRepository.save(deletedProduct); + + com.back.domain.wishlist.entity.Wishlist wishlist2 = + com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer) + .product(deletedProduct) + .build(); + wishlistRepository.save(wishlist2); + + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 8, null, null, null, null, null + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then: 삭제된 상품은 제외되어야 함 + assertAll( + () -> assertThat(result.getContent()).hasSize(1), + () -> assertThat(result.getContent().get(0).productName()).isEqualTo("테스트 상품"), + () -> assertThat(result.getSummary().totalWishItems()).isEqualTo(1) + ); + } + + @Test + @DisplayName("찜한 상품 목록 조회 - 최신 찜 순 정렬") + void getWishlist_SortsByLatestFirst() { + // Given: 3개의 찜 생성 + com.back.domain.wishlist.repository.WishlistRepository wishlistRepository = + applicationContext.getBean(com.back.domain.wishlist.repository.WishlistRepository.class); + + com.back.domain.product.product.entity.Product product1 = + createTestProduct("첫번째상품", "브랜드1"); + com.back.domain.product.product.entity.Product product2 = + createTestProduct("두번째상품", "브랜드2"); + com.back.domain.product.product.entity.Product product3 = + createTestProduct("세번째상품", "브랜드3"); + + wishlistRepository.save(com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer).product(product1).build()); + + try { + Thread.sleep(10); // 시간 차이를 두기 위해 + } catch (InterruptedException e) { + // ignore + } + + wishlistRepository.save(com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer).product(product2).build()); + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + // ignore + } + + wishlistRepository.save(com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer).product(product3).build()); + + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 8, null, null, null, null, null + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then: 최신 순으로 정렬되어야 함 + assertAll( + () -> assertThat(result.getContent()).hasSize(3), + () -> assertThat(result.getContent().get(0).productName()).isEqualTo("세번째상품"), + () -> assertThat(result.getContent().get(1).productName()).isEqualTo("두번째상품"), + () -> assertThat(result.getContent().get(2).productName()).isEqualTo("첫번째상품") + ); + } + + @Test + @DisplayName("찜한 상품 목록 조회 - 빈 목록일 때") + void getWishlist_ReturnsEmptyWhenNoWishlists() { + // Given: 찜이 없는 상태 + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 8, null, null, null, null, null + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then + assertAll( + () -> assertThat(result.getContent()).isEmpty(), + () -> assertThat(result.getSummary().totalWishItems()).isEqualTo(0), + () -> assertThat(result.getTotalElements()).isEqualTo(0), + () -> assertThat(result.getTotalPages()).isEqualTo(0), + () -> assertThat(result.isHasNext()).isFalse() + ); + } + + @Test + @DisplayName("찜한 상품 목록 조회 - brandName과 productName 모두 반환") + void getWishlist_ReturnsBothBrandNameAndProductName() { + // Given + com.back.domain.wishlist.repository.WishlistRepository wishlistRepository = + applicationContext.getBean(com.back.domain.wishlist.repository.WishlistRepository.class); + + com.back.domain.wishlist.entity.Wishlist wishlist = + com.back.domain.wishlist.entity.Wishlist.builder() + .user(testBuyer) + .product(testProduct) + .build(); + wishlistRepository.save(wishlist); + + com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest request = + new com.back.domain.dashboard.customer.dto.request.WishlistSearchRequest( + 0, 8, null, null, null, null, null + ); + + // When + com.back.domain.dashboard.customer.dto.response.WishlistResponse.List result = + dashboardService.getWishlist(testBuyer.getId(), request); + + // Then: brandName과 productName 모두 있어야 함 + com.back.domain.dashboard.customer.dto.response.WishlistResponse.Item item = + result.getContent().get(0); + assertAll( + () -> assertThat(item.brandName()).isNotNull(), + () -> assertThat(item.brandName()).isEqualTo("테스트 브랜드"), + () -> assertThat(item.productName()).isNotNull(), + () -> assertThat(item.productName()).isEqualTo("테스트 상품") + ); + } + + // Helper: 테스트 상품 생성 + private com.back.domain.product.product.entity.Product createTestProduct( + String productName, String brandName) { + com.back.domain.product.category.repository.CategoryRepository categoryRepository = + applicationContext.getBean(com.back.domain.product.category.repository.CategoryRepository.class); + + com.back.domain.product.category.entity.Category category = + categoryRepository.findById(1L) + .orElseGet(() -> categoryRepository.save( + com.back.domain.product.category.entity.Category.builder() + .categoryName("테스트카테고리") + .build() + )); + + com.back.domain.product.product.entity.Product product = + com.back.domain.product.product.entity.Product.builder() + .category(category) + .user(testArtist) + .name(productName) + .brandName(brandName) + .price(10000) + .discountRate(0) + .stock(100) + .bundleShippingAvailable(false) + .deliveryCharge(3000) + .additionalShippingCharge(3000) + .deliveryType(com.back.domain.product.product.entity.DeliveryType.PAID) + .description("테스트 상품 설명") + .sellingStatus(com.back.domain.product.product.entity.SellingStatus.SELLING) + .displayStatus(com.back.domain.product.product.entity.DisplayStatus.DISPLAYING) + .minQuantity(1) + .maxQuantity(10) + .productModelName("TEST-" + System.currentTimeMillis()) + .certification(false) + .origin("한국") + .material("플라스틱") + .size("10x10cm") + .isPlanned(false) + .isRestock(false) + .isDeleted(false) + .build(); + + return productRepository.save(product); + } + } \ No newline at end of file diff --git a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java index e08d9888..ba5dd0a2 100644 --- a/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java +++ b/src/test/java/com/back/domain/order/order/service/OrderServiceArtistRevenueTest.java @@ -75,6 +75,7 @@ void setUp() { // 관리자 생성 (테스트용) admin = User.createLocalUser("admin@test.com" + uniqueSuffix, "password", "관리자" + uniqueSuffix, "010-9999-9999"); + admin.becomeAdmin(); // 관리자 권한 부여 admin = userRepository.save(admin); // 카테고리 생성 From ecf514f1a6a3099895bb5c8d09372974aa5e8e51 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 09:30:18 +0900 Subject: [PATCH 12/31] =?UTF-8?q?refactor/354=20=EC=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DashboardServiceImpl.java | 185 ++++++++++++------ .../repository/WishlistRepository.java | 17 ++ 2 files changed, 142 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index b6a46d2e..adf7c6b7 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -43,6 +43,8 @@ public class DashboardServiceImpl implements DashboardService { private final com.back.domain.payment.cash.repository.CashTransactionRepository cashTransactionRepository; private final com.back.domain.payment.moriCash.repository.MoriCashPaymentRepository moriCashPaymentRepository; private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository; + private final com.back.domain.follow.repository.FollowRepository followRepository; + private final com.back.domain.wishlist.repository.WishlistRepository wishlistRepository; private static final DateTimeFormatter ORDER_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); private static final DateTimeFormatter FUNDING_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy. MM. dd"); @@ -118,7 +120,7 @@ public ArtistApplicationResponse.List getArtistApplications(Long userId, ArtistA int start = request.page() * request.size(); int end = Math.min(start + request.size(), applications.size()); List pagedApplications = - applications.subList(start, Math.min(end, applications.size())); + applications.subList(start, end); // 5. DTO 변환 List content = pagedApplications.stream() @@ -407,15 +409,23 @@ private String getProductThumbnailUrl(com.back.domain.product.product.entity.Pro return null; } - if (product.getImages() == null || product.getImages().isEmpty()) { + try { + if (product.getImages() == null || product.getImages().isEmpty()) { + return null; + } + + return product.getImages().stream() + .filter(image -> image != null && + image.getFileType() != null && + "THUMBNAIL".equals(image.getFileType().name())) + .findFirst() + .map(com.back.domain.product.product.entity.ProductImage::getFileUrl) + .orElse(null); + } catch (Exception e) { + // LazyInitializationException 등의 에러 발생 시 null 반환 + log.warn("상품 이미지 컬렉션 접근 실패 - productId: {}", product.getId(), e); return null; } - - return product.getImages().stream() - .filter(image -> "THUMBNAIL".equals(image.getFileType().name())) - .findFirst() - .map(com.back.domain.product.product.entity.ProductImage::getFileUrl) - .orElse(null); } /** @@ -448,76 +458,129 @@ private String mapOrderStatusText(com.back.domain.order.order.entity.OrderStatus @Override public FollowingResponse.List getFollowingArtists(Long userId, FollowingSearchRequest request) { - // TODO: 실제 데이터베이스 조회 로직 구현 log.debug("팔로우한 작가 목록 조회 - userId: {}, request: {}", userId, request); - // 사용자 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다.")); + // 1. 사용자 존재 여부 확인 + if (!userRepository.existsById(userId)) { + throw new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다."); + } - FollowingResponse.Profile profile = new FollowingResponse.Profile( - user.getId().toString(), - user.getName(), - user.getProfileImageUrl()); - - FollowingResponse.SummaryDto summary = - new FollowingResponse.SummaryDto(5); - - List content = Arrays.asList( - new FollowingResponse.Artist( - "artist_001", "감성작가", - "https://cdn.example.com/artists/artist_001/profile.jpg", - 500, "/artists/artist_001", - new FollowingResponse.FollowRelation("FOLLOWING", LocalDateTime.now()), - new FollowingResponse.Badge(true) - ), - new FollowingResponse.Artist( - "artist_002", "캐릭터작가", - "https://cdn.example.com/artists/artist_002/profile.jpg", - 123, "/artists/artist_002", - new FollowingResponse.FollowRelation("FOLLOWING", LocalDateTime.now().minusDays(1)), - new FollowingResponse.Badge(false) - ) - ); + // 2. 팔로우 목록 조회 + List follows = + followRepository.findFollowingsByFollowerId(userId); + + // 3. 페이징 처리 + int start = request.page() * request.size(); + int end = Math.min(start + request.size(), follows.size()); + List pagedFollows = + start < follows.size() ? follows.subList(start, end) : List.of(); + + // 4. DTO 변환 + List content = pagedFollows.stream() + .map(this::convertToFollowingArtist) + .collect(Collectors.toList()); + + // 5. 페이징 정보 계산 + int totalPages = (int) Math.ceil((double) follows.size() / request.size()); return new FollowingResponse.List( - profile, summary, content, + content, request.page(), request.size(), - 5, 1, false, false); + follows.size(), totalPages, + end < follows.size(), + request.page() > 0 + ); + } + + /** + * Follow 엔티티를 Artist DTO로 변환 + */ + private FollowingResponse.Artist convertToFollowingArtist(com.back.domain.follow.entity.Follow follow) { + com.back.domain.artist.entity.ArtistProfile artistProfile = follow.getFollowingArtist(); + User artistUser = artistProfile.getUser(); + + return new FollowingResponse.Artist( + artistProfile.getId().toString(), + artistProfile.getArtistName(), + artistUser.getProfileImageUrl(), + "/artists/" + artistProfile.getId() + ); } @Override public WishlistResponse.List getWishlist(Long userId, WishlistSearchRequest request) { - // TODO: 실제 데이터베이스 조회 로직 구현 log.debug("찜한 상품 목록 조회 - userId: {}, request: {}", userId, request); - WishlistResponse.SummaryDto summary = new WishlistResponse.SummaryDto(15); + // 1. 사용자 존재 여부 확인 + if (!userRepository.existsById(userId)) { + throw new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다."); + } + + // 2. 페이징 설정 + Pageable pageable = PageRequest.of(request.page(), request.size()); + // 3. 찜 목록 조회 (Product, Artist 정보 포함) + Page wishlistPage = + wishlistRepository.findByUserIdWithProductAndArtist(userId, pageable); + + // 4. DTO 변환 + List content = wishlistPage.getContent().stream() + .map(this::convertToWishlistItem) + .collect(Collectors.toList()); + + // 5. 통계 계산 + long totalWishItems = wishlistRepository.countByUserId(userId); + WishlistResponse.SummaryDto summary = new WishlistResponse.SummaryDto((int) totalWishItems); + + // 6. 일괄 작업 옵션 List bulkActions = List.of( new WishlistResponse.BulkAction("BULK_UNWISH", "선택 항목 해제", true) ); - List content = List.of( - new WishlistResponse.Item( - "w-001", 123157L, "0123157", "감성 일러스트 포스터", 25000, - new WishlistResponse.Artist("artist001", "감성작가"), - "https://cdn.example.com/p/123157/main.jpg", "SELLING", "2025-09-18", - LocalDateTime.now(), "/products/0123157", - new WishlistResponse.Permission(true) - ), - new WishlistResponse.Item( - "w-002", 123158L, "0123158", "귀여운 스티커 세트", 15000, - new WishlistResponse.Artist("artist002", "캐릭터작가"), - "https://cdn.example.com/p/123158/main.jpg", "SELLING", "2025-09-17", - LocalDateTime.now().minusDays(1), "/products/0123158", - new WishlistResponse.Permission(true) - ) - ); - return new WishlistResponse.List( summary, bulkActions, content, - request.page(), request.size(), - 15, 2, true, false); + wishlistPage.getNumber(), wishlistPage.getSize(), + wishlistPage.getTotalElements(), wishlistPage.getTotalPages(), + wishlistPage.hasNext(), wishlistPage.hasPrevious() + ); + } + + /** + * Wishlist 엔티티를 Item DTO로 변환 + */ + private WishlistResponse.Item convertToWishlistItem(com.back.domain.wishlist.entity.Wishlist wishlist) { + com.back.domain.product.product.entity.Product product = wishlist.getProduct(); + + // 작가 정보 (Product의 user가 작가) + WishlistResponse.Artist artist = null; + if (product.getUser() != null) { + artist = new WishlistResponse.Artist( + product.getUser().getId().toString(), + product.getUser().getName() + ); + } + + // 상품 상태 매핑 + String sellingStatus = product.getSellingStatus() != null ? + product.getSellingStatus().name() : "UNKNOWN"; + + // 썸네일 이미지 URL + String imageUrl = getProductThumbnailUrl(product); + + return new WishlistResponse.Item( + "w-" + wishlist.getId(), + product.getId(), + String.format("%07d", product.getId()), + product.getBrandName() != null ? product.getBrandName() : "", + product.getName(), + product.getPrice(), + artist, + imageUrl, + sellingStatus, + wishlist.getCreateDate(), + "/products/" + product.getId(), + new WishlistResponse.Permission(true) + ); } @Override @@ -616,7 +679,9 @@ private String getFundingThumbnailUrl(Funding funding) { // images 컬렉션에서 THUMBNAIL 타입 찾기 if (funding.getImages() != null && !funding.getImages().isEmpty()) { String thumbnailUrl = funding.getImages().stream() - .filter(image -> image != null && "THUMBNAIL".equals(image.getFileType().name())) + .filter(image -> image != null && + image.getFileType() != null && + "THUMBNAIL".equals(image.getFileType().name())) .findFirst() .map(FundingImage::getFileUrl) .orElse(null); diff --git a/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java index 4cdf18e0..e663b452 100644 --- a/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java +++ b/src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java @@ -1,7 +1,13 @@ package com.back.domain.wishlist.repository; import com.back.domain.wishlist.entity.Wishlist; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface WishlistRepository extends JpaRepository { // 찜 등록 여부 조회 @@ -10,5 +16,16 @@ public interface WishlistRepository extends JpaRepository { void deleteByUserIdAndProductId(Long userId, Long productId); // 상품별 찜 개수 조회 Long countByProductId(Long productId); + // 사용자별 찜 개수 조회 + long countByUserId(Long userId); + // 사용자의 찜 목록 조회 (Product, User(작가) 정보 포함) + @Query("SELECT w FROM Wishlist w " + + "JOIN FETCH w.product p " + + "LEFT JOIN FETCH p.user " + + "LEFT JOIN FETCH p.images " + + "WHERE w.user.id = :userId " + + "AND p.isDeleted = false " + + "ORDER BY w.createDate DESC") + Page findByUserIdWithProductAndArtist(@Param("userId") Long userId, Pageable pageable); } From 6d9406867a742db201901a26873b451c548a73d1 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 10:00:00 +0900 Subject: [PATCH 13/31] =?UTF-8?q?refactor/354=20=EC=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EA=B8=B0=EB=8A=A5=20=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DashboardServiceImpl.java | 16 +- .../scheduler/ProductPopularityScheduler.java | 2 +- .../service/DashboardServiceImplTest.java | 155 ------------------ 3 files changed, 9 insertions(+), 164 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index adf7c6b7..320d1753 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -497,12 +497,11 @@ public FollowingResponse.List getFollowingArtists(Long userId, FollowingSearchRe */ private FollowingResponse.Artist convertToFollowingArtist(com.back.domain.follow.entity.Follow follow) { com.back.domain.artist.entity.ArtistProfile artistProfile = follow.getFollowingArtist(); - User artistUser = artistProfile.getUser(); return new FollowingResponse.Artist( artistProfile.getId().toString(), artistProfile.getArtistName(), - artistUser.getProfileImageUrl(), + artistProfile.getProfileImageUrl(), "/artists/" + artistProfile.getId() ); } @@ -516,10 +515,11 @@ public WishlistResponse.List getWishlist(Long userId, WishlistSearchRequest requ throw new ServiceException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다."); } - // 2. 페이징 설정 - Pageable pageable = PageRequest.of(request.page(), request.size()); + // 2. 페이징 설정 (페이지 크기 8개 고정) + int fixedPageSize = 8; + Pageable pageable = PageRequest.of(request.page(), fixedPageSize); - // 3. 찜 목록 조회 (Product, Artist 정보 포함) + // 3. 찜 목록 조회 (Product, Artist 정보 포함, 삭제된 상품 제외) Page wishlistPage = wishlistRepository.findByUserIdWithProductAndArtist(userId, pageable); @@ -528,8 +528,8 @@ public WishlistResponse.List getWishlist(Long userId, WishlistSearchRequest requ .map(this::convertToWishlistItem) .collect(Collectors.toList()); - // 5. 통계 계산 - long totalWishItems = wishlistRepository.countByUserId(userId); + // 5. 통계 계산 (삭제되지 않은 상품만 카운트) + long totalWishItems = wishlistPage.getTotalElements(); WishlistResponse.SummaryDto summary = new WishlistResponse.SummaryDto((int) totalWishItems); // 6. 일괄 작업 옵션 @@ -539,7 +539,7 @@ public WishlistResponse.List getWishlist(Long userId, WishlistSearchRequest requ return new WishlistResponse.List( summary, bulkActions, content, - wishlistPage.getNumber(), wishlistPage.getSize(), + wishlistPage.getNumber(), fixedPageSize, wishlistPage.getTotalElements(), wishlistPage.getTotalPages(), wishlistPage.hasNext(), wishlistPage.hasPrevious() ); diff --git a/src/main/java/com/back/domain/product/product/scheduler/ProductPopularityScheduler.java b/src/main/java/com/back/domain/product/product/scheduler/ProductPopularityScheduler.java index 8b94d884..9101d953 100644 --- a/src/main/java/com/back/domain/product/product/scheduler/ProductPopularityScheduler.java +++ b/src/main/java/com/back/domain/product/product/scheduler/ProductPopularityScheduler.java @@ -33,7 +33,7 @@ public void updatePopularityScores() { long salesCount = orderItemRepository.countByProduct(product); // 찜 수 계산 (가중치 20%) - long wishlistCount = wishlistRepository.countByProduct(product); + long wishlistCount = wishlistRepository.countByProductId(product.getId()); // 리뷰 평점 (가중치 20%) Double averageRating = product.getAverageRating(); diff --git a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java index 344fec1e..d30aaf55 100644 --- a/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/customer/service/DashboardServiceImplTest.java @@ -802,161 +802,6 @@ void getFollowingArtists_ReturnsOnlyOwnFollows() { ); } - // ==================== Following 팔로우한 작가 목록 조회 테스트 ==================== - - @Test - @DisplayName("팔로우한 작가 목록 조회 - 실제 DB 연동 확인") - void getFollowingArtists_ReturnsRealData() { - // Given: 작가 프로필 생성 - com.back.domain.artist.entity.ArtistApplication application = - com.back.domain.artist.entity.ArtistApplication.builder() - .user(testArtist) - .ownerName("테스트작가") - .email("artist@example.com") - .phone("010-1234-5678") - .artistName("작가명입니다") - .businessNumber("123-45-67890") - .businessAddress("서울시") - .businessAddressDetail("강남구") - .businessZipCode("12345") - .telecomSalesNumber("2024-서울-0001") - .build(); - artistApplicationRepository.save(application); - - com.back.domain.artist.entity.ArtistProfile artistProfile = - com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); - artistProfile.updateProfile( - "https://cdn.example.com/artist.jpg", - "작가명입니다", - "@instagram", - "작가 소개", - null, null, null, null, null, null, null - ); - - com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository = - applicationContext.getBean(com.back.domain.artist.repository.ArtistProfileRepository.class); - artistProfile = artistProfileRepository.save(artistProfile); - - // Follow 생성 - com.back.domain.follow.repository.FollowRepository followRepository = - applicationContext.getBean(com.back.domain.follow.repository.FollowRepository.class); - - com.back.domain.follow.entity.Follow follow = - com.back.domain.follow.entity.Follow.create(testBuyer, artistProfile); - followRepository.save(follow); - - com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = - new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); - - // When - com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = - dashboardService.getFollowingArtists(testBuyer.getId(), request); - - // Then - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getContent()).hasSize(1), - () -> assertThat(result.getContent().get(0).artistName()).isEqualTo("작가명입니다"), - () -> assertThat(result.getContent().get(0).profileImageUrl()) - .isEqualTo("https://cdn.example.com/artist.jpg"), - () -> assertThat(result.getContent().get(0).artistPageUrl()).contains("/artists/"), - () -> assertThat(result.getTotalElements()).isEqualTo(1) - ); - } - - @Test - @DisplayName("팔로우한 작가 목록 조회 - 팔로우가 없는 경우") - void getFollowingArtists_ReturnsEmptyWhenNoFollows() { - // Given - com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = - new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); - - // When - com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = - dashboardService.getFollowingArtists(testBuyer.getId(), request); - - // Then - assertAll( - () -> assertThat(result.getContent()).isEmpty(), - () -> assertThat(result.getTotalElements()).isEqualTo(0), - () -> assertThat(result.getTotalPages()).isEqualTo(0), - () -> assertThat(result.isHasNext()).isFalse() - ); - } - - @Test - @DisplayName("팔로우한 작가 목록 조회 - 자신이 팔로우한 작가만 조회") - void getFollowingArtists_ReturnsOnlyOwnFollows() { - // Given: 다른 사용자 생성 - User otherUser = User.createLocalUser( - "other@example.com", - "password", - "다른사용자", - "01099999999" - ); - otherUser = userRepository.save(otherUser); - - // 작가 프로필 생성 - com.back.domain.artist.entity.ArtistApplication application = - com.back.domain.artist.entity.ArtistApplication.builder() - .user(testArtist) - .ownerName("테스트작가") - .email("artist@example.com") - .phone("010-1234-5678") - .artistName("공통작가") - .businessNumber("123-45-67890") - .businessAddress("서울시") - .businessAddressDetail("강남구") - .businessZipCode("12345") - .telecomSalesNumber("2024-서울-0001") - .build(); - artistApplicationRepository.save(application); - - com.back.domain.artist.entity.ArtistProfile artistProfile = - com.back.domain.artist.entity.ArtistProfile.fromApplication(testArtist, application); - - com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository = - applicationContext.getBean(com.back.domain.artist.repository.ArtistProfileRepository.class); - artistProfile = artistProfileRepository.save(artistProfile); - - // Follow 생성 - com.back.domain.follow.repository.FollowRepository followRepository = - applicationContext.getBean(com.back.domain.follow.repository.FollowRepository.class); - - // testBuyer가 팔로우 - com.back.domain.follow.entity.Follow follow1 = - com.back.domain.follow.entity.Follow.create(testBuyer, artistProfile); - followRepository.save(follow1); - - // otherUser도 같은 작가 팔로우 - com.back.domain.follow.entity.Follow follow2 = - com.back.domain.follow.entity.Follow.create(otherUser, artistProfile); - followRepository.save(follow2); - - com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest request = - new com.back.domain.dashboard.customer.dto.request.FollowingSearchRequest(0, 8); - - // When: testBuyer의 팔로우 목록 조회 - com.back.domain.dashboard.customer.dto.response.FollowingResponse.List result = - dashboardService.getFollowingArtists(testBuyer.getId(), request); - - // Then: testBuyer의 팔로우만 조회되어야 함 - assertAll( - () -> assertThat(result.getContent()).hasSize(1), - () -> assertThat(result.getTotalElements()).isEqualTo(1) - ); - - // When: otherUser의 팔로우 목록 조회 - com.back.domain.dashboard.customer.dto.response.FollowingResponse.List otherResult = - dashboardService.getFollowingArtists(otherUser.getId(), request); - - // Then: otherUser의 팔로우도 1개여야 함 - assertAll( - () -> assertThat(otherResult.getContent()).hasSize(1), - () -> assertThat(otherResult.getTotalElements()).isEqualTo(1) - ); - } - // ==================== Helper Methods (캐시 테스트용) ==================== private com.back.domain.payment.cash.entity.CashTransaction createChargeTransaction( From 87642d231716f67246f6dc6d498ad0575123d964 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 10:06:56 +0900 Subject: [PATCH 14/31] =?UTF-8?q?refactor/354=20=EC=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EA=B8=B0=EB=8A=A5=20=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/customer/controller/DashboardController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/controller/DashboardController.java b/src/main/java/com/back/domain/dashboard/customer/controller/DashboardController.java index a729ff51..4cd3b913 100644 --- a/src/main/java/com/back/domain/dashboard/customer/controller/DashboardController.java +++ b/src/main/java/com/back/domain/dashboard/customer/controller/DashboardController.java @@ -19,7 +19,7 @@ /** * 고객용 대시보드 컨트롤러 * 고객이 자신의 계정 정보, 주문 내역, 작가 신청 현황 등을 조회할 수 있는 대시보드 기능을 제공 - * 모든 API는 JWT 인증이 필요 + * 모든 API는 JWT 인증이 필요. * 제공 기능: *

    *
  • 계정 설정 조회 (프로필, 연락처, 보안)
  • From ce316049daeb0b01b51c6923fe66d71f5a91eddc Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 10:23:27 +0900 Subject: [PATCH 15/31] =?UTF-8?q?refactor/367=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=82=AC=EC=9A=A9=20=EC=9A=B0=EC=84=A0=20=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customer/service/DashboardServiceImpl.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index 320d1753..909d6f3e 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -494,14 +494,22 @@ public FollowingResponse.List getFollowingArtists(Long userId, FollowingSearchRe /** * Follow 엔티티를 Artist DTO로 변환 + * 프로필 이미지 우선순위: User.profileImageUrl > ArtistProfile.profileImageUrl */ private FollowingResponse.Artist convertToFollowingArtist(com.back.domain.follow.entity.Follow follow) { com.back.domain.artist.entity.ArtistProfile artistProfile = follow.getFollowingArtist(); + User artistUser = artistProfile.getUser(); + + // 프로필 이미지 URL 우선순위: User > ArtistProfile + String profileImageUrl = artistUser.getProfileImageUrl(); + if (profileImageUrl == null || profileImageUrl.isBlank()) { + profileImageUrl = artistProfile.getProfileImageUrl(); + } return new FollowingResponse.Artist( artistProfile.getId().toString(), artistProfile.getArtistName(), - artistProfile.getProfileImageUrl(), + profileImageUrl, // null인 경우 프론트에서 기본 이미지 표시 "/artists/" + artistProfile.getId() ); } From b7ee52d1194b8d8cd877e3378bd2e97c74c35828 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 11:03:45 +0900 Subject: [PATCH 16/31] =?UTF-8?q?refactor/367=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EB=AA=85=20=EC=A0=95=EB=A0=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 47 +++++++++++++------ .../order/repository/OrderRepository.java | 29 ++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index ffeb75ad..db9af141 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -719,19 +719,39 @@ public ArtistOrderResponse.List getOrders(Long artistId, ArtistOrderSearchReques } } - // 동적 정렬 처리 - org.springframework.data.domain.Sort sort = createOrderSort(request.sort(), request.order()); - PageRequest pageRequest = PageRequest.of(request.page(), request.size(), sort); + // 상품명 정렬 여부 확인 + boolean isProductNameSort = "productName".equals(request.sort()); + + Page orderPage; + + if (isProductNameSort) { + // 상품명 정렬: 별도 메서드 사용 + String direction = request.order() != null ? request.order() : "DESC"; + PageRequest pageRequest = PageRequest.of(request.page(), request.size()); + + orderPage = orderRepository.findOrdersByArtistSortedByProductName( + artistId, + orderStatus, + request.keyword(), + startDateTime, + endDateTime, + direction, + pageRequest + ); + } else { + // 일반 정렬: 동적 정렬 처리 + org.springframework.data.domain.Sort sort = createOrderSort(request.sort(), request.order()); + PageRequest pageRequest = PageRequest.of(request.page(), request.size(), sort); - // Repository를 통한 실제 DB 조회 - Page orderPage = orderRepository.findOrdersByArtist( - artistId, - orderStatus, - request.keyword(), - startDateTime, - endDateTime, - pageRequest - ); + orderPage = orderRepository.findOrdersByArtist( + artistId, + orderStatus, + request.keyword(), + startDateTime, + endDateTime, + pageRequest + ); + } // ID 리스트 추출 List orderIds = orderPage.getContent().stream() @@ -790,8 +810,7 @@ private org.springframework.data.domain.Sort createOrderSort(String sortField, S case "status" -> org.springframework.data.domain.Sort.by(direction, "status"); case "totalAmount" -> org.springframework.data.domain.Sort.by(direction, "totalAmount"); case "customerName" -> org.springframework.data.domain.Sort.by(direction, "user.name"); - case "productName" -> - org.springframework.data.domain.Sort.by(direction, "orderDate"); // 상품명 정렬은 복잡하므로 일단 주문일자로 대체 + case "productName" -> null; // 상품명 정렬은 Repository에서 처리 default -> org.springframework.data.domain.Sort.by(direction, "orderDate"); }; } diff --git a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java index e387a0ce..e77d3009 100644 --- a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java +++ b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java @@ -129,6 +129,35 @@ Page findOrdersByArtist( Pageable pageable ); + /** + * 작가별 주문 목록 조회 - 상품명 정렬용 + * 각 주문의 상품 중 이름이 가장 빠른 상품(ㄱ에 가까운)을 기준으로 정렬 + */ + @Query("SELECT DISTINCT o FROM Order o " + + "JOIN o.orderItems oi " + + "JOIN oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR o.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " o.user.name LIKE CONCAT('%', :keyword, '%') OR " + + " p.name LIKE CONCAT('%', :keyword, '%')) " + + "AND (:startDate IS NULL OR o.orderDate >= :startDate) " + + "AND (:endDate IS NULL OR o.orderDate <= :endDate) " + + "AND p.name = (SELECT MIN(p2.name) FROM OrderItem oi2 JOIN oi2.product p2 WHERE oi2.order = o AND p2.user.id = :artistId) " + + "ORDER BY " + + "CASE WHEN :direction = 'ASC' THEN p.name END ASC, " + + "CASE WHEN :direction = 'DESC' THEN p.name END DESC, " + + "o.orderDate DESC") + Page findOrdersByArtistSortedByProductName( + @Param("artistId") Long artistId, + @Param("status") OrderStatus status, + @Param("keyword") String keyword, + @Param("startDate") java.time.LocalDateTime startDate, + @Param("endDate") java.time.LocalDateTime endDate, + @Param("direction") String direction, + Pageable pageable + ); + /** * 작가별 주문 상세 정보 조회 (Fetch Join) */ From 9d1b5dcb3b54881bf38076deee262780491e82ac Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 11:18:05 +0900 Subject: [PATCH 17/31] =?UTF-8?q?refactor/367=20=EA=B5=90=ED=99=98=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC->db=20=EC=A0=95=EB=A0=AC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArtistDashboardServiceImpl.java | 92 ++++++------ .../repository/ExchangeRepository.java | 131 +++++++++++++++++- 2 files changed, 180 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index db9af141..6211dec0 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -1131,8 +1131,8 @@ private String convertRefundStatusToKorean(com.back.domain.order.refund.entity.R @Override public ArtistExchangeResponse.List getExchangeRequests(Long artistId, ArtistExchangeSearchRequest request) { - log.info("작가 교환 요청 목록 조회 시작 - artistId: {}, page: {}, size: {}, status: {}, keyword: {}", - artistId, request.page(), request.size(), request.status(), request.keyword()); + log.info("작가 교환 요청 목록 조회 시작 - artistId: {}, page: {}, size: {}, status: {}, keyword: {}, sort: {}, order: {}", + artistId, request.page(), request.size(), request.status(), request.keyword(), request.sort(), request.order()); // status 문자열을 ExchangeStatus enum으로 변환 com.back.domain.order.exchange.entity.Exchange.ExchangeStatus exchangeStatus = null; @@ -1149,24 +1149,50 @@ public ArtistExchangeResponse.List getExchangeRequests(Long artistId, ArtistExch } } - // Repository를 통한 실제 DB 조회 (최신순 정렬) - Page exchangePage = exchangeRepository.findExchangesByArtist( - artistId, - exchangeStatus, - request.keyword(), - PageRequest.of(request.page(), request.size()) - ); + // 정렬 방향 + String sortOrder = request.order() != null ? request.order() : "DESC"; + + // 정렬 필드에 따라 적절한 Repository 메서드 호출 + Page exchangePage; + PageRequest pageRequest = PageRequest.of(request.page(), request.size()); + + String sortField = request.sort() != null ? request.sort() : "requestDate"; + + switch (sortField) { + case "productName" -> { + // 상품명 정렬 (DB 쿼리) + if ("ASC".equalsIgnoreCase(sortOrder)) { + exchangePage = exchangeRepository.findExchangesByArtistSortedByProductNameAsc( + artistId, exchangeStatus, request.keyword(), pageRequest); + } else { + exchangePage = exchangeRepository.findExchangesByArtistSortedByProductNameDesc( + artistId, exchangeStatus, request.keyword(), pageRequest); + } + } + case "customerName" -> { + // 구매자 이름 정렬 (DB 쿼리) + if ("ASC".equalsIgnoreCase(sortOrder)) { + exchangePage = exchangeRepository.findExchangesByArtistSortedByCustomerNameAsc( + artistId, exchangeStatus, request.keyword(), pageRequest); + } else { + exchangePage = exchangeRepository.findExchangesByArtistSortedByCustomerNameDesc( + artistId, exchangeStatus, request.keyword(), pageRequest); + } + } + default -> { + // 주문일자, 상태 정렬 (Pageable Sort 사용) + org.springframework.data.domain.Sort sort = createExchangeSort(sortField, sortOrder); + pageRequest = PageRequest.of(request.page(), request.size(), sort); + exchangePage = exchangeRepository.findExchangesByArtist( + artistId, exchangeStatus, request.keyword(), pageRequest); + } + } // Entity → DTO 변환 List content = exchangePage.getContent().stream() .map(this::convertToExchangeDto) .toList(); - // 메모리에서 정렬 (간단한 정렬만 지원) - if (request.sort() != null && !request.sort().equals("requestDate")) { - content = sortExchangesInMemory(content, request.sort(), request.order()); - } - int totalPages = exchangePage.getTotalPages(); long totalElements = exchangePage.getTotalElements(); boolean hasNext = exchangePage.hasNext(); @@ -1187,36 +1213,18 @@ public ArtistExchangeResponse.List getExchangeRequests(Long artistId, ArtistExch } /** - * 메모리에서 교환 요청 정렬 처리 + * 교환 요청 정렬 생성 (requestDate, status 정렬용) */ - private List sortExchangesInMemory( - List list, String sort, String order) { - - boolean asc = "ASC".equalsIgnoreCase(order); + private org.springframework.data.domain.Sort createExchangeSort(String sortField, String sortOrder) { + org.springframework.data.domain.Sort.Direction direction = + "ASC".equalsIgnoreCase(sortOrder) + ? org.springframework.data.domain.Sort.Direction.ASC + : org.springframework.data.domain.Sort.Direction.DESC; - return list.stream() - .sorted((a, b) -> { - int cmp = 0; - switch (sort) { - case "productName": - String nameA = a.orderItem() != null ? a.orderItem().productName() : ""; - String nameB = b.orderItem() != null ? b.orderItem().productName() : ""; - cmp = nameA.compareTo(nameB); - break; - case "customerName": - String customerA = a.customer() != null ? a.customer().nickname() : ""; - String customerB = b.customer() != null ? b.customer().nickname() : ""; - cmp = customerA.compareTo(customerB); - break; - case "status": - cmp = a.status().compareTo(b.status()); - break; - default: - cmp = a.requestDate().compareTo(b.requestDate()); - } - return asc ? cmp : -cmp; - }) - .toList(); + return switch (sortField) { + case "status" -> org.springframework.data.domain.Sort.by(direction, "status"); + default -> org.springframework.data.domain.Sort.by(direction, "createDate"); // requestDate + }; } /** diff --git a/src/main/java/com/back/domain/order/exchange/repository/ExchangeRepository.java b/src/main/java/com/back/domain/order/exchange/repository/ExchangeRepository.java index 3e81f971..73d21d90 100644 --- a/src/main/java/com/back/domain/order/exchange/repository/ExchangeRepository.java +++ b/src/main/java/com/back/domain/order/exchange/repository/ExchangeRepository.java @@ -43,7 +43,136 @@ public interface ExchangeRepository extends JpaRepository { List findByUserWithItems(@Param("user") User user); /** - * 작가의 상품에 대한 교환 요청 조회 (검색만 쿼리, 정렬은 메모리) + * 작가의 상품에 대한 교환 요청 조회 (검색 + DB 정렬) + * - 상품명 정렬 (ASC) + */ + @Query(value = "SELECT DISTINCT e FROM Exchange e " + + "JOIN FETCH e.order o " + + "JOIN FETCH e.user u " + + "LEFT JOIN FETCH e.exchangeItems ei " + + "LEFT JOIN FETCH ei.orderItem oi " + + "LEFT JOIN FETCH oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " u.name LIKE CONCAT('%', :keyword, '%')) " + + "ORDER BY p.name ASC", + countQuery = "SELECT COUNT(DISTINCT e) FROM Exchange e " + + "JOIN e.exchangeItems ei " + + "JOIN ei.orderItem oi " + + "JOIN oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " e.user.name LIKE CONCAT('%', :keyword, '%'))") + Page findExchangesByArtistSortedByProductNameAsc( + @Param("artistId") Long artistId, + @Param("status") Exchange.ExchangeStatus status, + @Param("keyword") String keyword, + Pageable pageable + ); + + /** + * 작가의 상품에 대한 교환 요청 조회 (검색 + DB 정렬) + * - 상품명 정렬 (DESC) + */ + @Query(value = "SELECT DISTINCT e FROM Exchange e " + + "JOIN FETCH e.order o " + + "JOIN FETCH e.user u " + + "LEFT JOIN FETCH e.exchangeItems ei " + + "LEFT JOIN FETCH ei.orderItem oi " + + "LEFT JOIN FETCH oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " u.name LIKE CONCAT('%', :keyword, '%')) " + + "ORDER BY p.name DESC", + countQuery = "SELECT COUNT(DISTINCT e) FROM Exchange e " + + "JOIN e.exchangeItems ei " + + "JOIN ei.orderItem oi " + + "JOIN oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " e.user.name LIKE CONCAT('%', :keyword, '%'))") + Page findExchangesByArtistSortedByProductNameDesc( + @Param("artistId") Long artistId, + @Param("status") Exchange.ExchangeStatus status, + @Param("keyword") String keyword, + Pageable pageable + ); + + /** + * 작가의 상품에 대한 교환 요청 조회 (검색 + DB 정렬) + * - 구매자 이름 정렬 (ASC) + */ + @Query(value = "SELECT DISTINCT e FROM Exchange e " + + "JOIN FETCH e.order o " + + "JOIN FETCH e.user u " + + "LEFT JOIN FETCH e.exchangeItems ei " + + "LEFT JOIN FETCH ei.orderItem oi " + + "LEFT JOIN FETCH oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " u.name LIKE CONCAT('%', :keyword, '%')) " + + "ORDER BY u.name ASC", + countQuery = "SELECT COUNT(DISTINCT e) FROM Exchange e " + + "JOIN e.exchangeItems ei " + + "JOIN ei.orderItem oi " + + "JOIN oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " e.user.name LIKE CONCAT('%', :keyword, '%'))") + Page findExchangesByArtistSortedByCustomerNameAsc( + @Param("artistId") Long artistId, + @Param("status") Exchange.ExchangeStatus status, + @Param("keyword") String keyword, + Pageable pageable + ); + + /** + * 작가의 상품에 대한 교환 요청 조회 (검색 + DB 정렬) + * - 구매자 이름 정렬 (DESC) + */ + @Query(value = "SELECT DISTINCT e FROM Exchange e " + + "JOIN FETCH e.order o " + + "JOIN FETCH e.user u " + + "LEFT JOIN FETCH e.exchangeItems ei " + + "LEFT JOIN FETCH ei.orderItem oi " + + "LEFT JOIN FETCH oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " u.name LIKE CONCAT('%', :keyword, '%')) " + + "ORDER BY u.name DESC", + countQuery = "SELECT COUNT(DISTINCT e) FROM Exchange e " + + "JOIN e.exchangeItems ei " + + "JOIN ei.orderItem oi " + + "JOIN oi.product p " + + "WHERE p.user.id = :artistId " + + "AND (:status IS NULL OR e.status = :status) " + + "AND (:keyword IS NULL OR :keyword = '' OR " + + " p.name LIKE CONCAT('%', :keyword, '%') OR " + + " e.user.name LIKE CONCAT('%', :keyword, '%'))") + Page findExchangesByArtistSortedByCustomerNameDesc( + @Param("artistId") Long artistId, + @Param("status") Exchange.ExchangeStatus status, + @Param("keyword") String keyword, + Pageable pageable + ); + + /** + * 작가의 상품에 대한 교환 요청 조회 (검색 + 기본 정렬) + * - 주문일자, 상태 정렬 (Pageable로 처리) */ @Query(value = "SELECT DISTINCT e FROM Exchange e " + "JOIN FETCH e.order o " + From ccd719df674672671fd5cfe0a76784dc2016a98f Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 11:41:16 +0900 Subject: [PATCH 18/31] =?UTF-8?q?refactor/367=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=ED=98=84=ED=99=A9-=EC=83=81=ED=92=88=EB=AA=85=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=B6=94=EA=B0=80,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArtistSettlementSearchRequest.java | 4 +-- .../service/ArtistDashboardServiceImpl.java | 36 ++++++++++++++----- .../order/repository/OrderRepository.java | 25 +++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/artist/dto/request/ArtistSettlementSearchRequest.java b/src/main/java/com/back/domain/dashboard/artist/dto/request/ArtistSettlementSearchRequest.java index 29e2cd0c..5866b2dc 100644 --- a/src/main/java/com/back/domain/dashboard/artist/dto/request/ArtistSettlementSearchRequest.java +++ b/src/main/java/com/back/domain/dashboard/artist/dto/request/ArtistSettlementSearchRequest.java @@ -36,8 +36,8 @@ public record ArtistSettlementSearchRequest( Integer size, /** 정렬 기준 */ - @Pattern(regexp = "^(date|grossAmount|commission|netAmount|status)$", - message = "sort는 date, grossAmount, commission, netAmount, status 중 하나여야 합니다") + @Pattern(regexp = "^(date|productName|grossAmount|commission|netAmount|status)$", + message = "sort는 date, productName, grossAmount, commission, netAmount, status 중 하나여야 합니다") String sort, /** 정렬 방향 */ diff --git a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java index 6211dec0..a14f3076 100644 --- a/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java @@ -1667,11 +1667,25 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( ArtistSettlementSearchRequest request, Long artistId) { - // 1. 배송 완료된 주문 조회 (정렬 없이 전체 조회) - List allOrders = - orderRepository.findDeliveredOrdersByArtistInPeriod(artist, startDate, endDate); + // 1. 정렬 필드 확인 + String sortField = request.sort() != null ? request.sort() : "date"; + String sortOrder = request.order() != null ? request.order() : "DESC"; + boolean isProductNameSort = "productName".equals(sortField); + + // 2. 배송 완료된 주문 조회 + List allOrders; + + if (isProductNameSort) { + // 상품명 정렬: DB에서 정렬된 상태로 조회 + allOrders = orderRepository.findDeliveredOrdersByArtistInPeriodSortedByProductName( + artist, startDate, endDate, sortOrder); + } else { + // 일반 조회 (정렬은 메모리에서 처리) + allOrders = orderRepository.findDeliveredOrdersByArtistInPeriod( + artist, startDate, endDate); + } - // 2. 주문을 OrderItem 단위로 변환 (작가의 상품만) + // 3. 주문을 OrderItem 단위로 변환 (작가의 상품만) List settlementItems = allOrders.stream() .flatMap(order -> order.getOrderItems().stream() .filter(item -> item.getProduct().getUser().getId().equals(artistId)) @@ -1684,17 +1698,19 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( ))) .toList(); - // 3. 정렬 적용 - settlementItems = sortSettlementItems(settlementItems, request.sort(), request.order()); + // 4. 정렬 적용 (상품명 정렬이 아닌 경우만 메모리 정렬) + if (!isProductNameSort) { + settlementItems = sortSettlementItems(settlementItems, sortField, sortOrder); + } - // 4. 페이징 처리 + // 5. 페이징 처리 int start = request.page() * request.size(); int end = Math.min(start + request.size(), settlementItems.size()); List pagedItems = start < settlementItems.size() ? settlementItems.subList(start, end) : List.of(); - // 5. DTO 변환 + // 6. DTO 변환 List content = pagedItems.stream() .map(this::convertToSettlementDtoFromOrder) .toList(); @@ -1716,7 +1732,8 @@ private ArtistSettlementResponse.Table createSettlementTableFromOrders( } /** - * 정산 아이템 정렬 + * 정산 아이템 정렬 (메모리 정렬) + * productName 정렬은 DB에서 처리하므로 여기서는 제외 */ private List sortSettlementItems( List items, String sortField, String sortOrder) { @@ -1726,6 +1743,7 @@ private List sortSettlementItems( return items.stream() .sorted((a, b) -> { int cmp = switch (sortField) { + case "productName" -> a.productName.compareTo(b.productName); case "grossAmount" -> Integer.compare(a.grossAmount, b.grossAmount); case "commission" -> Integer.compare(a.grossAmount / 10, b.grossAmount / 10); case "netAmount" -> Integer.compare(a.grossAmount * 9 / 10, b.grossAmount * 9 / 10); diff --git a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java index e77d3009..a71f325b 100644 --- a/src/main/java/com/back/domain/order/order/repository/OrderRepository.java +++ b/src/main/java/com/back/domain/order/order/repository/OrderRepository.java @@ -328,4 +328,29 @@ List findDeliveredOrdersByArtistInPeriod( @Param("startDate") java.time.LocalDateTime startDate, @Param("endDate") java.time.LocalDateTime endDate ); + + /** + * - 특정 기간 내 DELIVERED 상태 주문만 + * - 작가가 판매한 상품의 주문만 + * - 상품명 기준 정렬 (ASC/DESC) + */ + @Query("SELECT DISTINCT o FROM Order o " + + "JOIN o.orderItems oi " + + "JOIN oi.product p " + + "WHERE p.user = :artist " + + "AND o.status = com.back.domain.order.order.entity.OrderStatus.DELIVERED " + + "AND o.orderDate >= :startDate " + + "AND o.orderDate <= :endDate " + + "AND p.name = (SELECT MIN(p2.name) FROM OrderItem oi2 JOIN oi2.product p2 " + + " WHERE oi2.order = o AND p2.user = :artist) " + + "ORDER BY " + + "CASE WHEN :direction = 'ASC' THEN p.name END ASC, " + + "CASE WHEN :direction = 'DESC' THEN p.name END DESC, " + + "o.orderDate DESC") + List findDeliveredOrdersByArtistInPeriodSortedByProductName( + @Param("artist") User artist, + @Param("startDate") java.time.LocalDateTime startDate, + @Param("endDate") java.time.LocalDateTime endDate, + @Param("direction") String direction + ); } From fec3a5aa543e63e37cb0ffa72e1c17ff9b9d3d87 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 14:16:54 +0900 Subject: [PATCH 19/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C-=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/AdminProductResponse.java | 28 ++----------------- .../service/AdminDashboardServiceImpl.java | 12 +++----- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminProductResponse.java b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminProductResponse.java index 752c341d..31509f8f 100644 --- a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminProductResponse.java +++ b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminProductResponse.java @@ -31,39 +31,17 @@ public record AdminProductResponse( * 상품 정보 */ public record Product( - /** 상품 ID */ + /** 상품 ID (기본키) */ Long productId, /** 상품 번호 */ String productNumber, /** 상품명 */ String name, - /** 작가 정보 */ - Artist artist, + /** 작가명 */ + String artistName, /** 판매 상태 */ String sellingStatus, - /** 카테고리 정보 */ - Category category, /** 등록일 */ LocalDate registeredAt ) {} - - /** - * 작가 정보 - */ - public record Artist( - /** 작가 ID */ - Long id, - /** 작가명 */ - String name - ) {} - - /** - * 카테고리 정보 - */ - public record Category( - /** 카테고리 ID */ - Long id, - /** 카테고리명 */ - String name - ) {} } diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index 49dfe2a3..d3b78a0b 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -325,19 +325,15 @@ private AdminProductResponse.Product convertToProductDto(Product product) { ? product.getProductUuid().toString() : String.valueOf(product.getId()); + // 작가명 추출 (User 또는 ArtistProfile에서) + String artistName = product.getUser().getName(); + return new AdminProductResponse.Product( product.getId(), productNumber, product.getName(), - new AdminProductResponse.Artist( - product.getUser().getId(), - product.getUser().getName() // User에서 작가명 추출 - ), + artistName, product.getSellingStatus().name(), - new AdminProductResponse.Category( - product.getCategory().getId(), - product.getCategory().getCategoryName() // categoryName 사용 - ), product.getCreateDate().toLocalDate() ); } From 44851bead625ae90ab9a82e5358c614d2b1dbf5c Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 14:27:02 +0900 Subject: [PATCH 20/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C-=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=88=98?= =?UTF-8?q?=EB=A3=8C=EC=9C=A8=20=EC=A0=95=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminDashboardServiceImpl.java | 2 +- .../AdminDashboardServiceImplTest.java | 52 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index d3b78a0b..78cf0411 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -515,7 +515,7 @@ private String mapUserSortField(String sort) { case "memberId" -> "email"; case "nickname" -> "name"; case "artistName" -> "name"; - case "commissionRate" -> "id"; // TODO: User 엔티티에 commissionRate 필드 추가 시 수정 + case "commissionRate" -> "grade"; // 수수료율 정렬은 회원등급으로 처리 (작가=GUARDIAN, 일반=SPROUT) case "grade" -> "grade"; case "accountStatus" -> "status"; case "joinedAt" -> "createDate"; diff --git a/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java index c6f584f4..e8ce11c6 100644 --- a/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java @@ -8,7 +8,10 @@ import com.back.domain.order.order.repository.OrderRepository; import com.back.domain.product.category.entity.Category; import com.back.domain.product.category.repository.CategoryRepository; -import com.back.domain.product.product.entity.*; +import com.back.domain.product.product.entity.DeliveryType; +import com.back.domain.product.product.entity.DisplayStatus; +import com.back.domain.product.product.entity.Product; +import com.back.domain.product.product.entity.SellingStatus; import com.back.domain.product.product.repository.ProductRepository; import com.back.domain.user.entity.Role; import com.back.domain.user.entity.User; @@ -30,6 +33,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -440,6 +444,52 @@ private void setupSecurityContext() { }); } + @Test + @DisplayName("사용자 목록 조회 - 수수료율 정렬 (실제로는 회원등급 정렬)") + void getUsers_수수료율정렬() { + // Given - 수수료율 오름차순 정렬 (실제로는 grade 정렬) + AdminUserSearchRequest request = new AdminUserSearchRequest( + 0, 20, null, null, null, null, null, null, null, + "commissionRate", "ASC" + ); + + // When + AdminUserResponse response = adminDashboardService.getUsers(request); + + // Then + assertThat(response.content()).isNotEmpty(); + // 등급 순서: SPROUT(일반) < GRASS < TREE < FOREST < GUARDIAN(작가) + // 따라서 일반 유저(수수료율 null)가 먼저, 작가(수수료율 10%)가 나중에 나와야 함 + + List users = response.content(); + for (int i = 0; i < users.size() - 1; i++) { + String currentGrade = users.get(i).grade(); + String nextGrade = users.get(i + 1).grade(); + + // GUARDIAN(작가) 앞에는 GUARDIAN이 아닌 등급이 와야 함 + if ("GUARDIAN".equals(nextGrade)) { + assertThat(currentGrade).isIn("SPROUT", "GRASS", "TREE", "FOREST", "GUARDIAN"); + } + } + } + + @Test + @DisplayName("사용자 목록 조회 - 수수료율 내림차순 정렬") + void getUsers_수수료율내림차순정렬() { + // Given - 수수료율 내림차순 정렬 (실제로는 grade 역순 정렬) + AdminUserSearchRequest request = new AdminUserSearchRequest( + 0, 20, null, null, null, null, null, null, null, + "commissionRate", "DESC" + ); + + // When + AdminUserResponse response = adminDashboardService.getUsers(request); + + // Then + assertThat(response.content()).isNotEmpty(); + // 내림차순이므로 GUARDIAN(작가, 10%)가 먼저, 일반 유저(null)가 나중에 나와야 함 + } + // ========== 펀딩 승인 대기 목록 테스트 ========== @Test From c5d9b2ae98f7f111bb96eb81f8e7c128f09325f9 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 14:37:17 +0900 Subject: [PATCH 21/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C-=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=8E=80=EB=94=A9=20=EB=AA=A9=EB=A1=9D=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20Resoponse=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/AdminFundingResponse.java | 62 ++----------------- .../service/AdminDashboardServiceImpl.java | 29 ++------- 2 files changed, 12 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java index d670e02b..5cf95de4 100644 --- a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java +++ b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java @@ -27,36 +27,18 @@ public record AdminFundingResponse( * 펀딩 정보 */ public record Funding( - /** 펀딩 ID */ + /** 펀딩 ID (기본키) */ Long fundingId, - /** 펀딩 제목 */ - String title, /** 작가 정보 */ Artist artist, - /** 카테고리 정보 */ - Category category, - /** 펀딩 상태 */ - String status, - /** 목표 금액 */ - long targetAmount, - /** 현재 금액 */ - long currentAmount, + /** 펀딩 제목 */ + String title, /** 달성률 (%) */ int achievementRate, - /** 후원자 수 */ - int supporterCount, + /** 펀딩 상태 */ + String status, /** 마감일 */ - String endDate, - /** 등록일 */ - String registeredAt, - /** 남은 일수 */ - int remainingDays, - /** 메인 이미지 */ - String mainImage, - /** 권한 정보 */ - Permissions permissions, - /** 플래그 정보 */ - Flags flags + String endDate ) {} /** @@ -65,39 +47,7 @@ public record Funding( public record Artist( /** 작가 ID */ Long id, - /** 회원 ID */ - String memberId, /** 작가명 */ String name ) {} - - /** - * 카테고리 정보 - */ - public record Category( - /** 카테고리 ID */ - Long id, - /** 카테고리명 */ - String name - ) {} - - /** - * 권한 정보 - */ - public record Permissions( - /** 일시정지 가능 여부 */ - boolean canPause, - /** 판매 전환 승인 가능 여부 */ - boolean canApproveSale - ) {} - - /** - * 플래그 정보 - */ - public record Flags( - /** 목표 달성 여부 */ - boolean goalAchieved, - /** 마감 임박 여부 */ - boolean dueSoon - ) {} } diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index 78cf0411..1b37f92a 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -640,43 +640,26 @@ public AdminFundingResponse getFundings(AdminFundingSearchRequest request) { } /** - * Funding Entity → DTO 변환 + * Funding Entity → DTO 변환 (화면 표시 필드만) */ private AdminFundingResponse.Funding convertToFundingDto(Funding funding) { + // 달성률 계산 int achievementRate = funding.getTargetAmount() > 0 ? (int) ((funding.getCollectedAmount() * 100) / funding.getTargetAmount()) : 0; - long remainingDays = java.time.temporal.ChronoUnit.DAYS.between( - LocalDateTime.now(), - funding.getEndDate() - ); - - DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy. MM. dd"); return new AdminFundingResponse.Funding( funding.getId(), - funding.getTitle(), new AdminFundingResponse.Artist( funding.getUser().getId(), - funding.getUser().getEmail() != null ? funding.getUser().getEmail() : "N/A", funding.getUser().getName() ), - new AdminFundingResponse.Category(1L, "미분류"), - funding.getStatus().name(), - funding.getTargetAmount(), - funding.getCollectedAmount(), + funding.getTitle(), achievementRate, - funding.getParticipantCount(), - funding.getEndDate().format(dateFormatter), - funding.getCreateDate().format(dateFormatter), - (int) Math.max(0, remainingDays), - funding.getImageUrl(), - new AdminFundingResponse.Permissions(true, true), - new AdminFundingResponse.Flags( - achievementRate >= 100, - remainingDays <= 7 && remainingDays > 0 - ) + funding.getStatus().name(), + funding.getEndDate().format(dateFormatter) ); } From d40e491831f6bc3e97e33841fd3b4b2560c92495 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 14:58:15 +0900 Subject: [PATCH 22/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C-=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=8E=80=EB=94=A9=20=EB=AA=A9=EB=A1=9D=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20Resoponse=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminFundingApprovalResponse.java | 32 ++++--------------- .../dto/response/AdminFundingResponse.java | 18 +++-------- .../service/AdminDashboardServiceImpl.java | 26 ++++----------- .../AdminDashboardControllerTest.java | 18 +++++------ .../AdminDashboardServiceImplTest.java | 2 +- 5 files changed, 29 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingApprovalResponse.java b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingApprovalResponse.java index 385418e9..3755e937 100644 --- a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingApprovalResponse.java +++ b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingApprovalResponse.java @@ -25,36 +25,18 @@ public record AdminFundingApprovalResponse( ) { /** - * 펀딩 승인 대기 정보 + * 펀딩 승인 대기 정보 (화면 표시 필드만) */ public record FundingApproval( /** 펀딩 ID */ Long fundingId, - /** 펀딩 제목 */ - String title, - /** 작가 정보 */ - Artist artist, - /** 목표 금액 */ - long targetAmount, - /** 펀딩 시작일 */ - String startDate, - /** 펀딩 종료일 */ - String endDate, - /** 펀딩 신청일 */ - String registeredAt, - /** 메인 이미지 */ - String mainImage - ) {} - - /** - * 작가 정보 - */ - public record Artist( /** 작가 ID */ - Long id, + Long artistId, /** 작가명 */ - String name, - /** 이메일 */ - String email + String artistName, + /** 펀딩 제목 */ + String title, + /** 신청일자 */ + String registeredAt ) {} } diff --git a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java index 5cf95de4..0fdd0187 100644 --- a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java +++ b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminFundingResponse.java @@ -24,13 +24,15 @@ public record AdminFundingResponse( ) { /** - * 펀딩 정보 + * 펀딩 정보 (화면 표시 필드만, 평면 구조) */ public record Funding( /** 펀딩 ID (기본키) */ Long fundingId, - /** 작가 정보 */ - Artist artist, + /** 작가 ID */ + Long artistId, + /** 작가명 */ + String artistName, /** 펀딩 제목 */ String title, /** 달성률 (%) */ @@ -40,14 +42,4 @@ public record Funding( /** 마감일 */ String endDate ) {} - - /** - * 작가 정보 - */ - public record Artist( - /** 작가 ID */ - Long id, - /** 작가명 */ - String name - ) {} } diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index 1b37f92a..f0e19523 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -640,7 +640,7 @@ public AdminFundingResponse getFundings(AdminFundingSearchRequest request) { } /** - * Funding Entity → DTO 변환 (화면 표시 필드만) + * Funding Entity → DTO 변환 (화면 표시 필드만, 평면 구조) */ private AdminFundingResponse.Funding convertToFundingDto(Funding funding) { // 달성률 계산 @@ -652,10 +652,8 @@ private AdminFundingResponse.Funding convertToFundingDto(Funding funding) { return new AdminFundingResponse.Funding( funding.getId(), - new AdminFundingResponse.Artist( - funding.getUser().getId(), - funding.getUser().getName() - ), + funding.getUser().getId(), + funding.getUser().getName(), funding.getTitle(), achievementRate, funding.getStatus().name(), @@ -998,27 +996,17 @@ public AdminFundingApprovalResponse getFundingApprovals(AdminFundingApprovalSear } /** - * Funding Entity → FundingApproval DTO 변환 + * Funding Entity → FundingApproval DTO 변환 (화면 표시 필드만) */ private AdminFundingApprovalResponse.FundingApproval convertToFundingApprovalDto(Funding funding) { DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy. MM. dd"); - // 작가 정보 - AdminFundingApprovalResponse.Artist artist = new AdminFundingApprovalResponse.Artist( - funding.getUser().getId(), - funding.getUser().getName(), - funding.getUser().getEmail() != null ? funding.getUser().getEmail() : "N/A" - ); - return new AdminFundingApprovalResponse.FundingApproval( funding.getId(), + funding.getUser().getId(), + funding.getUser().getName(), funding.getTitle(), - artist, - funding.getTargetAmount(), - funding.getStartDate().format(dateFormatter), - funding.getEndDate().format(dateFormatter), - funding.getCreateDate().format(dateFormatter), - funding.getImageUrl() + funding.getCreateDate().format(dateFormatter) ); } diff --git a/src/test/java/com/back/domain/dashboard/admin/controller/AdminDashboardControllerTest.java b/src/test/java/com/back/domain/dashboard/admin/controller/AdminDashboardControllerTest.java index c3b7e849..53ea3fba 100644 --- a/src/test/java/com/back/domain/dashboard/admin/controller/AdminDashboardControllerTest.java +++ b/src/test/java/com/back/domain/dashboard/admin/controller/AdminDashboardControllerTest.java @@ -164,7 +164,7 @@ void getProducts_Success_WithRealData() throws Exception { .andExpect(jsonPath("$.data.totalElements").isNumber()) .andExpect(jsonPath("$.data.content[0].productId").exists()) .andExpect(jsonPath("$.data.content[0].name").exists()) - .andExpect(jsonPath("$.data.content[0].artist.name").exists()); + .andExpect(jsonPath("$.data.content[0].artistName").exists()); } @Test @@ -427,10 +427,10 @@ void rejectArtistApplication_Success() throws Exception { String rejectionReason = "제출 서류가 불충분합니다."; String requestBody = String.format(""" - { - "rejectionReason": "%s" - } - """, rejectionReason); + { + "rejectionReason": "%s" + } + """, rejectionReason); // When & Then mockMvc.perform(post("/api/dashboard/admin/artist-applications/{applicationId}/reject", saved.getId()) @@ -461,10 +461,10 @@ void rejectArtistApplication_Fail_NoReason() throws Exception { ArtistApplication saved = artistApplicationRepository.save(application); String requestBody = """ - { - "rejectionReason": "" - } - """; + { + "rejectionReason": "" + } + """; // When & Then mockMvc.perform(post("/api/dashboard/admin/artist-applications/{applicationId}/reject", saved.getId()) diff --git a/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java b/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java index e8ce11c6..09df6f66 100644 --- a/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java +++ b/src/test/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImplTest.java @@ -554,7 +554,7 @@ private void setupSecurityContext() { // Then response.content().forEach(funding -> - assertThat(funding.artist().id()).isEqualTo(artistUser.getId()) + assertThat(funding.artistId()).isEqualTo(artistUser.getId()) ); } From 3dfda6747918df1f6a1131c0e5542050948d6898 Mon Sep 17 00:00:00 2001 From: yoostill Date: Wed, 15 Oct 2025 15:15:58 +0900 Subject: [PATCH 23/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C-=EC=9E=85?= =?UTF-8?q?=EC=A0=90=20=EC=8A=B9=EC=9D=B8=20=EC=A0=95=EB=A0=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArtistApplicationRepository.java | 53 +++++++++++++++---- .../ArtistApplicationAdminService.java | 9 +++- .../AdminArtistApplicationResponse.java | 4 +- .../service/AdminDashboardServiceImpl.java | 37 +++++++------ .../ArtistApplicationAdminServiceTest.java | 20 +++++-- 5 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java b/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java index 23f3c66e..b42c693e 100644 --- a/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java +++ b/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java @@ -18,19 +18,52 @@ public interface ArtistApplicationRepository extends JpaRepository findFirstByUserIdOrderByCreateDateDesc(Long userId); // 최근 신청 1건 조회 boolean existsByUserIdAndStatus(Long userId, ApplicationStatus status); // 중복 신청 방지용 - 특정 상태의 작가 신청서 존재 여부 확인 - // ==== 관리자용 ==== // - Page findAllByOrderByCreateDateDesc(Pageable pageable); // 모든 작가 신청서 조회 - Page findByStatusOrderByCreateDateDesc( // 상태별 작가 신청서 조회 - ApplicationStatus status, Pageable pageable); long countByStatus(ApplicationStatus status); // 대시보드 통계용 - 특정 상태의 작가 신청서 개수 조회 - // 검색 - @Query("SELECT a FROM ArtistApplication a WHERE a.artistName LIKE %:artistName% ORDER BY a.createDate DESC") - Page findByArtistNameContainingOrderByCreateDateDesc( // 작가명 검색 - @Param("artistName") String artistName, Pageable pageable - ); - // userId로 조회 Optional findByUserId(Long userId); + + // ==== 관리자 대시보드용 - 동적 정렬 지원 ==== // + /** + * 관리자 입점 신청 목록 조회 (검색 + 필터링 + 동적 정렬) + * + * 기능: + * - 작가명, 이메일, 작가ID로 검색 (keyword) + * - 상태별 필터링 (status) + * - 작가ID, 작가명, 신청일자, 상태로 정렬 (sort, order) + * + * @param keyword 검색어 (작가명/이메일/작가ID) + * @param status 신청 상태 (PENDING/APPROVED/REJECTED/CANCELLED) + * @param sort 정렬 기준 (artistId/artistName/submittedAt/status) + * @param order 정렬 순서 (ASC/DESC) + * @param pageable 페이징 정보 + * @return 입점 신청 목록 + */ + @Query(""" + SELECT a FROM ArtistApplication a + LEFT JOIN FETCH a.user u + WHERE (:keyword IS NULL OR + LOWER(a.artistName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR + LOWER(u.email) LIKE LOWER(CONCAT('%', :keyword, '%')) OR + CAST(u.id AS string) LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR a.status = :status) + ORDER BY + CASE WHEN :sort = 'artistId' AND :order = 'ASC' THEN u.id END ASC, + CASE WHEN :sort = 'artistId' AND :order = 'DESC' THEN u.id END DESC, + CASE WHEN :sort = 'artistName' AND :order = 'ASC' THEN a.artistName END ASC, + CASE WHEN :sort = 'artistName' AND :order = 'DESC' THEN a.artistName END DESC, + CASE WHEN :sort = 'submittedAt' AND :order = 'ASC' THEN a.createDate END ASC, + CASE WHEN :sort = 'submittedAt' AND :order = 'DESC' THEN a.createDate END DESC, + CASE WHEN :sort = 'status' AND :order = 'ASC' THEN a.status END ASC, + CASE WHEN :sort = 'status' AND :order = 'DESC' THEN a.status END DESC, + a.createDate DESC + """) + Page findArtistApplicationsForAdmin( + @Param("keyword") String keyword, + @Param("status") ApplicationStatus status, + @Param("sort") String sort, + @Param("order") String order, + Pageable pageable + ); } diff --git a/src/main/java/com/back/domain/artist/service/ArtistApplicationAdminService.java b/src/main/java/com/back/domain/artist/service/ArtistApplicationAdminService.java index 6411ff81..5c29b0c7 100644 --- a/src/main/java/com/back/domain/artist/service/ArtistApplicationAdminService.java +++ b/src/main/java/com/back/domain/artist/service/ArtistApplicationAdminService.java @@ -34,8 +34,15 @@ public class ArtistApplicationAdminService { * TODO: 대시보드 파트와 겹치는 부분이어서 통합 고려 */ public Page getAllApplications(Pageable pageable) { + // 새로운 동적 쿼리 사용 (keyword, status null로 전체 조회, 기본 정렬: submittedAt DESC) Page applications = - artistApplicationRepository.findAllByOrderByCreateDateDesc(pageable); + artistApplicationRepository.findArtistApplicationsForAdmin( + null, // keyword: 검색어 없음 (전체 조회) + null, // status: 상태 필터 없음 (전체 조회) + "submittedAt", // 신청일자 기준 정렬 + "DESC", // 최신순 + pageable + ); return applications.map(ArtistApplicationSimpleResponse::from); } diff --git a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminArtistApplicationResponse.java b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminArtistApplicationResponse.java index 1a5ef5ec..c18d8210 100644 --- a/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminArtistApplicationResponse.java +++ b/src/main/java/com/back/domain/dashboard/admin/dto/response/AdminArtistApplicationResponse.java @@ -59,7 +59,9 @@ public record Application( * 작가 정보 */ public record Artist( - /** 회원 ID */ + /** 작가 ID (User ID) */ + Long artistId, + /** 회원 ID (이메일) */ String memberId, /** 작가명 */ String name diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index f0e19523..c864aa35 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -111,7 +111,13 @@ public AdminOverviewResponse getOverview(AdminOverviewRequest request) { // 5. 승인 대기 알림 // 작가 입점 신청 승인 대기 (최근 2건) List pendingApplications = artistApplicationRepository - .findByStatusOrderByCreateDateDesc(ApplicationStatus.PENDING, PageRequest.of(0, 2)) + .findArtistApplicationsForAdmin( + null, // keyword: 검색어 없음 + ApplicationStatus.PENDING, // status: PENDING만 + "submittedAt", // 신청일자 기준 + "DESC", // 최신순 + PageRequest.of(0, 2) + ) .getContent(); List artistApprovals = pendingApplications.stream() @@ -737,26 +743,22 @@ private String mapFundingSortField(String sort) { public AdminArtistApplicationResponse getArtistApplications(AdminArtistApplicationSearchRequest request) { CustomUserDetails adminUser = validateAdminAuthentication(); - Pageable pageable = buildPageable(request.page(), request.size(), request.sort(), request.order(), - sort -> "createDate"); - - Page applicationPage; + Pageable pageable = PageRequest.of(request.page(), request.size()); + // 새로운 동적 정렬 쿼리 사용 + ApplicationStatus status = null; if (request.status() != null && !request.status().isBlank()) { - ApplicationStatus appStatus = ApplicationStatus.valueOf(request.status()); - if (request.keyword() != null && !request.keyword().isBlank()) { - applicationPage = artistApplicationRepository.findByArtistNameContainingOrderByCreateDateDesc(request.keyword(), pageable); - } else { - applicationPage = artistApplicationRepository.findByStatusOrderByCreateDateDesc(appStatus, pageable); - } - } else { - if (request.keyword() != null && !request.keyword().isBlank()) { - applicationPage = artistApplicationRepository.findByArtistNameContainingOrderByCreateDateDesc(request.keyword(), pageable); - } else { - applicationPage = artistApplicationRepository.findAllByOrderByCreateDateDesc(pageable); - } + status = ApplicationStatus.valueOf(request.status()); } + Page applicationPage = artistApplicationRepository.findArtistApplicationsForAdmin( + request.keyword(), + status, + request.sort(), + request.order(), + pageable + ); + long totalApplications = artistApplicationRepository.count(); long pending = artistApplicationRepository.countByStatus(ApplicationStatus.PENDING); long approved = artistApplicationRepository.countByStatus(ApplicationStatus.APPROVED); @@ -794,6 +796,7 @@ private AdminArtistApplicationResponse.Application convertToApplicationDto(Artis return new AdminArtistApplicationResponse.Application( application.getId(), new AdminArtistApplicationResponse.Artist( + application.getUser().getId(), // 작가 ID 추가 application.getUser().getEmail() != null ? application.getUser().getEmail() : "N/A", application.getArtistName() ), diff --git a/src/test/java/com/back/domain/artist/service/ArtistApplicationAdminServiceTest.java b/src/test/java/com/back/domain/artist/service/ArtistApplicationAdminServiceTest.java index 85b7c543..a884a385 100644 --- a/src/test/java/com/back/domain/artist/service/ArtistApplicationAdminServiceTest.java +++ b/src/test/java/com/back/domain/artist/service/ArtistApplicationAdminServiceTest.java @@ -122,8 +122,14 @@ void getAllApplications_Success() { 2 ); - given(artistApplicationRepository.findAllByOrderByCreateDateDesc(pageable)) - .willReturn(applicationPage); + // ⭐ 수정: 새로운 동적 쿼리 메서드 mocking + given(artistApplicationRepository.findArtistApplicationsForAdmin( + null, // keyword + null, // status + "submittedAt", // sort + "DESC", // order + pageable + )).willReturn(applicationPage); // when Page result = adminService.getAllApplications(pageable); @@ -133,7 +139,15 @@ void getAllApplications_Success() { assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getContent().get(0).artistName()).isEqualTo("아티스트1"); assertThat(result.getContent().get(1).artistName()).isEqualTo("아티스트2"); - verify(artistApplicationRepository).findAllByOrderByCreateDateDesc(pageable); + + // ⭐ 수정: 새로운 메서드 호출 검증 + verify(artistApplicationRepository).findArtistApplicationsForAdmin( + null, + null, + "submittedAt", + "DESC", + pageable + ); } } From 1f0dbc702d5665b6a657feac421a0ef7cf8adf07 Mon Sep 17 00:00:00 2001 From: yoostill Date: Thu, 16 Oct 2025 09:57:21 +0900 Subject: [PATCH 24/31] =?UTF-8?q?refactor/367=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DashboardServiceImpl.java | 25 +++++++++++++++++++ .../product/service/ProductService.java | 7 +----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java index 909d6f3e..935bf24a 100644 --- a/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/customer/service/DashboardServiceImpl.java @@ -373,6 +373,18 @@ private OrderResponse.Summary convertToOrderSummary(com.back.domain.order.order. */ private OrderResponse.Product convertToProductDto(com.back.domain.order.orderItem.entity.OrderItem orderItem) { com.back.domain.product.product.entity.Product product = orderItem.getProduct(); + + // 상품이 삭제되었거나 null인 경우 처리 + if (product == null) { + log.warn("OrderItem의 Product가 null입니다 - orderItemId: {}", orderItem.getId()); + return new OrderResponse.Product( + null, + "삭제된 상품", + orderItem.getQuantity(), + orderItem.getPrice().intValue(), + null + ); + } return new OrderResponse.Product( product.getId(), @@ -388,6 +400,19 @@ private OrderResponse.Product convertToProductDto(com.back.domain.order.orderIte */ private OrderResponse.OrderItem convertToOrderItemDto(com.back.domain.order.orderItem.entity.OrderItem orderItem) { com.back.domain.product.product.entity.Product product = orderItem.getProduct(); + + // 상품이 삭제되었거나 null인 경우 처리 + if (product == null) { + log.warn("OrderItem의 Product가 null입니다 - orderItemId: {}", orderItem.getId()); + return new OrderResponse.OrderItem( + orderItem.getId(), + null, + "삭제된 상품", + orderItem.getQuantity(), + orderItem.getPrice().intValue(), + null + ); + } return new OrderResponse.OrderItem( orderItem.getId(), diff --git a/src/main/java/com/back/domain/product/product/service/ProductService.java b/src/main/java/com/back/domain/product/product/service/ProductService.java index ba45567c..75a060dc 100644 --- a/src/main/java/com/back/domain/product/product/service/ProductService.java +++ b/src/main/java/com/back/domain/product/product/service/ProductService.java @@ -298,11 +298,6 @@ public ShareLinkResponse generateShareLink(UUID productUuid, String platform, Cu // 베이스 URL 생성 (프론트엔드 URL) String baseUrl = frontendUrl + "/product/" + productUuid; - // UTM 파라미터 생성 - // utm_source: 유입 경로 (instagram, youtube 등) - // utm_medium: 매체 타입 (social 고정) - // utm_campaign: 캠페인 (작가 ID 포함) - // utm_content: 추가 정보 (product_share 고정) String utmParams = String.format( "?utm_source=%s&utm_medium=social&utm_campaign=artist_%d&utm_content=product_share", normalizedPlatform, @@ -319,7 +314,7 @@ public ShareLinkResponse generateShareLink(UUID productUuid, String platform, Cu normalizedPlatform, artistId, productUuid, - product.getName() // 상품명을 설명으로 사용 + product.getName() ); } From 80cc3d55712349333b7b7683bcd431d69bbc357c Mon Sep 17 00:00:00 2001 From: yoostill Date: Thu, 16 Oct 2025 10:28:52 +0900 Subject: [PATCH 25/31] =?UTF-8?q?refactor/367=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20null?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminDashboardServiceImpl.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index c864aa35..146b2f56 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -121,6 +121,7 @@ public AdminOverviewResponse getOverview(AdminOverviewRequest request) { .getContent(); List artistApprovals = pendingApplications.stream() + .filter(app -> app.getUser() != null) // User가 null인 경우 필터링 .map(app -> new AdminOverviewResponse.ArtistApproval( app.getUser().getId(), app.getArtistName(), @@ -137,6 +138,7 @@ public AdminOverviewResponse getOverview(AdminOverviewRequest request) { .getContent(); List fundingApprovals = pendingFundings.stream() + .filter(funding -> funding.getUser() != null) // User가 null인 경우 필터링 .map(funding -> new AdminOverviewResponse.FundingApproval( funding.getId(), funding.getTitle(), @@ -649,6 +651,13 @@ public AdminFundingResponse getFundings(AdminFundingSearchRequest request) { * Funding Entity → DTO 변환 (화면 표시 필드만, 평면 구조) */ private AdminFundingResponse.Funding convertToFundingDto(Funding funding) { + // User null 체크 (FK 제약조건상 발생하면 안되지만 방어적 처리) + if (funding.getUser() == null) { + log.error("Funding의 User가 null입니다 - fundingId: {}", funding.getId()); + throw new ServiceException("DATA_INTEGRITY_ERROR", + "펀딩 데이터 무결성 오류 - fundingId: " + funding.getId()); + } + // 달성률 계산 int achievementRate = funding.getTargetAmount() > 0 ? (int) ((funding.getCollectedAmount() * 100) / funding.getTargetAmount()) @@ -791,6 +800,13 @@ public AdminArtistApplicationResponse getArtistApplications(AdminArtistApplicati * ArtistApplication Entity → DTO 변환 */ private AdminArtistApplicationResponse.Application convertToApplicationDto(ArtistApplication application) { + // User null 체크 (FK 제약조건상 발생하면 안되지만 방어적 처리) + if (application.getUser() == null) { + log.error("ArtistApplication의 User가 null입니다 - applicationId: {}", application.getId()); + throw new ServiceException("DATA_INTEGRITY_ERROR", + "입점 신청 데이터 무결성 오류 - applicationId: " + application.getId()); + } + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); return new AdminArtistApplicationResponse.Application( From 8f692156db16256fbfae75f586f9130a280691be Mon Sep 17 00:00:00 2001 From: yoostill Date: Thu, 16 Oct 2025 10:51:53 +0900 Subject: [PATCH 26/31] refactor/ --- .../domain/artist/repository/ArtistApplicationRepository.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java b/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java index b42c693e..dd97fe58 100644 --- a/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java +++ b/src/main/java/com/back/domain/artist/repository/ArtistApplicationRepository.java @@ -45,7 +45,6 @@ public interface ArtistApplicationRepository extends JpaRepository Date: Thu, 16 Oct 2025 11:32:25 +0900 Subject: [PATCH 27/31] =?UTF-8?q?fix/405=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=ED=98=84=ED=99=A9=EC=97=90=EC=84=9C=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/admin/service/AdminDashboardServiceImpl.java | 2 +- .../com/back/domain/user/repository/UserRepository.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java index 146b2f56..d17698be 100644 --- a/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java +++ b/src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java @@ -87,7 +87,7 @@ public AdminOverviewResponse getOverview(AdminOverviewRequest request) { long totalUsers = userRepository.count(); long totalProducts = productRepository.count(); long totalFundings = fundingRepository.count(); - long artistCount = userRepository.findAll().stream().filter(User::isArtist).count(); + long artistCount = userRepository.countArtists(); // 최적화: findAll() 대신 COUNT 쿼리 사용 // 2. 오늘 날짜 기준 통계 LocalDate today = LocalDate.now(); diff --git a/src/main/java/com/back/domain/user/repository/UserRepository.java b/src/main/java/com/back/domain/user/repository/UserRepository.java index bac907b0..74bcdd9d 100644 --- a/src/main/java/com/back/domain/user/repository/UserRepository.java +++ b/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -72,4 +72,10 @@ java.util.List findDaily @org.springframework.data.repository.query.Param("startDate") java.time.LocalDateTime startDate, @org.springframework.data.repository.query.Param("endDate") java.time.LocalDateTime endDate ); + + /** + * 관리자 대시보드 - 전체 작가 수 조회 (최적화) + */ + @org.springframework.data.jpa.repository.Query("SELECT COUNT(u) FROM User u WHERE u.role = com.back.domain.user.entity.Role.ARTIST AND u.isArtistVerified = true") + long countArtists(); } From 82c07f789dafc4f8f64f8a795a912eea9beaeff1 Mon Sep 17 00:00:00 2001 From: yoostill Date: Sun, 19 Oct 2025 20:43:04 +0900 Subject: [PATCH 28/31] =?UTF-8?q?fix/405=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=ED=98=84=ED=99=A9=EC=97=90=EC=84=9C=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/repository/ProductRepository.java | 8 +- .../controller/RecommendationController.java | 223 +++++++++++++----- 2 files changed, 165 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java index 8ae8ceb2..6995a29f 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java @@ -18,7 +18,13 @@ public interface ProductRepository extends JpaRepository, ProductCustomRepository, JpaSpecificationExecutor { Optional findByProductUuid(UUID productUuid); - + + // 태그 정보를 포함한 상품 조회 (추천 시스템용) + @Query("SELECT DISTINCT p FROM Product p " + + "LEFT JOIN FETCH p.productTags pt " + + "LEFT JOIN FETCH pt.tag " + + "WHERE p.productUuid = :productUuid") + Optional findByProductUuidWithTags(@Param("productUuid") UUID productUuid); // 재고 감소용 - Pessimistic Write Lock (동시성 제어) @Lock(LockModeType.PESSIMISTIC_WRITE) diff --git a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java index 16eb9610..f06e0581 100644 --- a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java @@ -92,84 +92,150 @@ public class RecommendationController { ) public ResponseEntity> matchProducts( @Valid @RequestBody PreferenceRequest request) { - + try { log.info("===== 추천 요청 시작 ====="); log.info("선호 태그: {}", request.preferences()); log.info("가격: {}-{}", request.minPrice(), request.maxPrice()); - - // 1. 상위 선호 태그 추출 (점수 0.3 이상) + + // 1. 입력값 유효성 검증 + if (request.preferences() == null || request.preferences().isEmpty()) { + log.warn("선호 태그가 비어있음"); + return ResponseEntity.ok(RsData.of("400", + "선호 태그 정보가 필요합니다", + new MatchResponse(List.of()))); + } + + // 가격 범위 검증 + int minPrice = Optional.ofNullable(request.minPrice()).orElse(0); + int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999); + + if (minPrice < 0 || maxPrice < 0) { + log.warn("잘못된 가격 범위: min={}, max={}", minPrice, maxPrice); + return ResponseEntity.ok(RsData.of("400", + "가격은 0 이상이어야 합니다", + new MatchResponse(List.of()))); + } + + if (minPrice > maxPrice) { + log.warn("최소 가격이 최대 가격보다 큼: min={}, max={}", minPrice, maxPrice); + return ResponseEntity.ok(RsData.of("400", + "최소 가격은 최대 가격보다 클 수 없습니다", + new MatchResponse(List.of()))); + } + + // 2. 상위 선호 태그 추출 (점수 0.3 이상) List topTagNames = request.preferences().entrySet().stream() .filter(entry -> entry.getValue() >= 0.3) // 0.5 → 0.3으로 낮춤 .sorted(Map.Entry.comparingByValue().reversed()) .map(Map.Entry::getKey) .toList(); - + log.info("선호도 0.3 이상 태그: {}", topTagNames); - + if (topTagNames.isEmpty()) { log.warn("선호도 0.3 이상인 태그가 없음"); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", + return ResponseEntity.ok(RsData.of("200", + "선호도가 충분히 높은 태그가 없습니다. 테스트를 다시 진행해주세요.", + new MatchResponse(List.of()))); + } + + // 3. 태그명 → 태그ID 변환 + List tagIds; + try { + tagIds = tagDictionary.toIds(topTagNames); + log.info("변환된 태그 ID: {}", tagIds); + } catch (IllegalArgumentException e) { + log.error("태그 변환 중 오류: {}", e.getMessage()); + return ResponseEntity.ok(RsData.of("400", + "잘못된 태그 정보입니다: " + e.getMessage(), + new MatchResponse(List.of()))); + } catch (Exception e) { + log.error("태그 변환 중 예상치 못한 오류", e); + return ResponseEntity.ok(RsData.of("500", + "태그 처리 중 오류가 발생했습니다", new MatchResponse(List.of()))); } - - // 2. 태그명 → 태그ID 변환 - List tagIds = tagDictionary.toIds(topTagNames); - - log.info("변환된 태그 ID: {}", tagIds); - + if (tagIds.isEmpty()) { log.warn("유효한 태그 ID를 찾을 수 없음. 입력된 태그명: {}", topTagNames); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다 (태그를 찾을 수 없음)", + return ResponseEntity.ok(RsData.of("200", + "해당하는 상품 태그를 찾을 수 없습니다", new MatchResponse(List.of()))); } - - // 3. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) + + // 4. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) int page = 0; // 첫 페이지만 int size = 50; // 50개 후보 조회 → 점수 계산 → 상위 3개 선택 - int minPrice = Optional.ofNullable(request.minPrice()).orElse(0); - int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999); - + log.info("후보군 조회: {}개 (이 중 최적 3개 선택)", size); log.info("가격 필터: {}원 ~ {}원", minPrice, maxPrice); - + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createDate")); - + // 기존 findProducts 메서드 활용 (태그 필터링 포함) - ProductListResponse response = productRepository.findProducts( - null, // categoryId: 전체 카테고리 - tagIds, // 선호 태그로 필터링 - minPrice, // 최소 가격 - maxPrice, // 최대 가격 - null, // deliveryType: 전체 - "newest", // 신상품순 - pageable - ); - - log.info("findProducts 결과: {}개 상품", response.products().size()); - + ProductListResponse response; + try { + response = productRepository.findProducts( + null, // categoryId: 전체 카테고리 + tagIds, // 선호 태그로 필터링 + minPrice, // 최소 가격 + maxPrice, // 최대 가격 + null, // deliveryType: 전체 + "newest", // 신상품순 + pageable + ); + log.info("findProducts 결과: {}개 상품", response.products().size()); + } catch (Exception e) { + log.error("상품 조회 중 오류 발생", e); + return ResponseEntity.ok(RsData.of("500", + "상품 조회 중 오류가 발생했습니다", + new MatchResponse(List.of()))); + } + + if (response.products().isEmpty()) { + log.warn("조건에 맞는 상품이 없음 (findProducts 단계)"); + return ResponseEntity.ok(RsData.of("200", + "조건에 맞는 상품이 없습니다. 가격 범위를 조정해보세요.", + new MatchResponse(List.of()))); + } + // ProductInfo → Product 변환이 필요하므로, UUID로 다시 조회 (태그 포함) - List filtered = response.products().stream() - .map(productInfo -> { - Optional product = productRepository.findByProductUuidWithTags(productInfo.productUuid()); - if (product.isEmpty()) { - log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid()); - } - return product; - }) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - - log.info("UUID 재조회 결과: {}개 상품", filtered.size()); - + List filtered; + try { + filtered = response.products().stream() + .map(productInfo -> { + try { + Optional product = productRepository.findByProductUuidWithTags(productInfo.productUuid()); + if (product.isEmpty()) { + log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid()); + } + return product; + } catch (Exception e) { + log.error("상품 UUID 조회 중 오류: {}", productInfo.productUuid(), e); + return Optional.empty(); + } + }) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + log.info("UUID 재조회 결과: {}개 상품", filtered.size()); + } catch (Exception e) { + log.error("상품 상세 조회 중 오류 발생", e); + return ResponseEntity.ok(RsData.of("500", + "상품 상세 정보 조회 중 오류가 발생했습니다", + new MatchResponse(List.of()))); + } + if (filtered.isEmpty()) { - log.warn("조건에 맞는 상품이 없음"); - return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", + log.warn("조건에 맞는 상품이 없음 (UUID 재조회 단계)"); + return ResponseEntity.ok(RsData.of("200", + "조건에 맞는 상품이 없습니다", new MatchResponse(List.of()))); } - - // 4. 매칭 스코어 계산 및 정렬 + + // 5. 매칭 스코어 계산 및 정렬 List ranked; try { log.info("매칭 스코어 계산 시작..."); @@ -179,39 +245,66 @@ public ResponseEntity> matchProducts( request.specPrefs() ); log.info("✅ 매칭 스코어 계산 완료: {}개", ranked.size()); + } catch (IllegalArgumentException e) { + log.error("❌ 매칭 스코어 계산 중 잘못된 입력: {}", e.getMessage()); + return ResponseEntity.ok(RsData.of("400", + "추천 계산 중 오류: " + e.getMessage(), + new MatchResponse(List.of()))); + } catch (NullPointerException e) { + log.error("❌ 매칭 스코어 계산 중 null 값 발견", e); + return ResponseEntity.ok(RsData.of("500", + "추천 계산 중 데이터 오류가 발생했습니다", + new MatchResponse(List.of()))); } catch (Exception e) { log.error("❌ 매칭 스코어 계산 중 오류 발생", e); - return ResponseEntity.ok(RsData.of("500", - "추천 시스템 오류: " + e.getMessage(), + return ResponseEntity.ok(RsData.of("500", + "추천 계산 중 오류가 발생했습니다", + new MatchResponse(List.of()))); + } + + if (ranked == null || ranked.isEmpty()) { + log.warn("매칭 스코어 계산 결과가 비어있음"); + return ResponseEntity.ok(RsData.of("200", + "추천할 수 있는 상품이 없습니다", new MatchResponse(List.of()))); } - - // 5. 상위 3개만 추출 (프론트 요구사항) + + // 6. 상위 3개만 추출 (프론트 요구사항) final int RECOMMENDATION_LIMIT = 3; List topN = ranked.stream() .limit(RECOMMENDATION_LIMIT) .toList(); - + log.info("===== 최종 추천: {}개 상품 (고정) =====", topN.size()); - + MatchResponse matchResponse = new MatchResponse(topN); - RsData result = RsData.of("200", - topN.size() + "개 상품을 추천했습니다", + RsData result = RsData.of("200", + topN.size() + "개 상품을 추천했습니다", matchResponse); - + log.info("🎉 응답 반환 준비 완료"); - log.info("Response: resultCode={}, msg={}, data.size={}", + log.info("Response: resultCode={}, msg={}, data.size={}", result.resultCode(), result.msg(), topN.size()); - + return ResponseEntity.ok(result); - + + } catch (IllegalArgumentException e) { + log.error("❌ 잘못된 입력값 오류", e); + return ResponseEntity.ok(RsData.of("400", + "잘못된 요청입니다: " + e.getMessage(), + new MatchResponse(List.of()))); + } catch (NullPointerException e) { + log.error("❌ Null 값 참조 오류", e); + return ResponseEntity.ok(RsData.of("500", + "데이터 처리 중 오류가 발생했습니다", + new MatchResponse(List.of()))); } catch (Exception e) { log.error("❌❌❌ RecommendationController에서 예상치 못한 오류 발생 ❌❌❌", e); log.error("오류 타입: {}", e.getClass().getName()); log.error("오류 메시지: {}", e.getMessage()); - - return ResponseEntity.ok(RsData.of("500", - "추천 시스템에 문제가 발생했습니다: " + e.getMessage(), + + return ResponseEntity.ok(RsData.of("500", + "추천 시스템에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.", new MatchResponse(List.of()))); } } From 6631ed0930ac0a968de976c229f552c3ae1ae65d Mon Sep 17 00:00:00 2001 From: yoostill Date: Sun, 19 Oct 2025 20:54:11 +0900 Subject: [PATCH 29/31] =?UTF-8?q?fix/405=20gpt=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/repository/ProductCustomRepositoryImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java index 4a2ced65..cd27c31c 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java @@ -83,7 +83,8 @@ public ProductListResponse findProducts( var query = queryFactory .select(p) .from(p) - .leftJoin(p.images, img).on(img.fileType.eq(FileType.THUMBNAIL)) + .leftJoin(p.images, img).fetchJoin() + .on(img.fileType.eq(FileType.THUMBNAIL)) .where(builder) .distinct(); From ddbd569d023037833bea4d7a6ba10f3102e488b5 Mon Sep 17 00:00:00 2001 From: yoostill Date: Sun, 19 Oct 2025 20:54:22 +0900 Subject: [PATCH 30/31] =?UTF-8?q?fix/405=20gpt=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RecommendationController.java | 93 +++++++++---------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java index f06e0581..b732bc05 100644 --- a/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/back/domain/recommendation/controller/RecommendationController.java @@ -59,30 +59,28 @@ public class RecommendationController { example = """ { "resultCode": "200", - "msg": "5개 상품을 추천했습니다", - "data": { - "recommendations": [ - { - "rank": 1, - "matchScore": 2.4, - "product": { - "productUuid": "550e8400-...", - "imageUrl": "https://...", - "brandName": "문구브랜드", - "name": "부드러운 4B 연필", - "price": 15000, - "discountRate": 10, - "discountPrice": 13500, - "rating": 4.5 - }, - "matchedTags": [ - {"name": "부드러운", "yourScore": 0.9}, - {"name": "실용적인", "yourScore": 0.8} - ], - "reason": "'부드러운·실용적인·데일리' 선호와 일치" - } - ] - } + "msg": "3개 상품을 추천했습니다", + "data": [ + { + "rank": 1, + "matchScore": 2.4, + "product": { + "productUuid": "550e8400-...", + "imageUrl": "https://...", + "brandName": "문구브랜드", + "name": "부드러운 4B 연필", + "price": 15000, + "discountRate": 10, + "discountPrice": 13500, + "rating": 4.5 + }, + "matchedTags": [ + {"name": "부드러운", "yourScore": 0.9}, + {"name": "실용적인", "yourScore": 0.8} + ], + "reason": "'부드러운·실용적인·데일리' 선호와 일치" + } + ] } """ ) @@ -90,11 +88,12 @@ public class RecommendationController { ) } ) - public ResponseEntity> matchProducts( + public ResponseEntity>> matchProducts( @Valid @RequestBody PreferenceRequest request) { try { log.info("===== 추천 요청 시작 ====="); + log.info("🔵 요청 전체: {}", request); log.info("선호 태그: {}", request.preferences()); log.info("가격: {}-{}", request.minPrice(), request.maxPrice()); @@ -103,7 +102,7 @@ public ResponseEntity> matchProducts( log.warn("선호 태그가 비어있음"); return ResponseEntity.ok(RsData.of("400", "선호 태그 정보가 필요합니다", - new MatchResponse(List.of()))); + List.of())); } // 가격 범위 검증 @@ -114,14 +113,14 @@ public ResponseEntity> matchProducts( log.warn("잘못된 가격 범위: min={}, max={}", minPrice, maxPrice); return ResponseEntity.ok(RsData.of("400", "가격은 0 이상이어야 합니다", - new MatchResponse(List.of()))); + List.of())); } if (minPrice > maxPrice) { log.warn("최소 가격이 최대 가격보다 큼: min={}, max={}", minPrice, maxPrice); return ResponseEntity.ok(RsData.of("400", "최소 가격은 최대 가격보다 클 수 없습니다", - new MatchResponse(List.of()))); + List.of())); } // 2. 상위 선호 태그 추출 (점수 0.3 이상) @@ -137,7 +136,7 @@ public ResponseEntity> matchProducts( log.warn("선호도 0.3 이상인 태그가 없음"); return ResponseEntity.ok(RsData.of("200", "선호도가 충분히 높은 태그가 없습니다. 테스트를 다시 진행해주세요.", - new MatchResponse(List.of()))); + List.of())); } // 3. 태그명 → 태그ID 변환 @@ -149,19 +148,19 @@ public ResponseEntity> matchProducts( log.error("태그 변환 중 오류: {}", e.getMessage()); return ResponseEntity.ok(RsData.of("400", "잘못된 태그 정보입니다: " + e.getMessage(), - new MatchResponse(List.of()))); + List.of())); } catch (Exception e) { log.error("태그 변환 중 예상치 못한 오류", e); return ResponseEntity.ok(RsData.of("500", "태그 처리 중 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } if (tagIds.isEmpty()) { log.warn("유효한 태그 ID를 찾을 수 없음. 입력된 태그명: {}", topTagNames); return ResponseEntity.ok(RsData.of("200", "해당하는 상품 태그를 찾을 수 없습니다", - new MatchResponse(List.of()))); + List.of())); } // 4. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택) @@ -190,14 +189,14 @@ public ResponseEntity> matchProducts( log.error("상품 조회 중 오류 발생", e); return ResponseEntity.ok(RsData.of("500", "상품 조회 중 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } if (response.products().isEmpty()) { log.warn("조건에 맞는 상품이 없음 (findProducts 단계)"); return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다. 가격 범위를 조정해보세요.", - new MatchResponse(List.of()))); + List.of())); } // ProductInfo → Product 변환이 필요하므로, UUID로 다시 조회 (태그 포함) @@ -225,14 +224,14 @@ public ResponseEntity> matchProducts( log.error("상품 상세 조회 중 오류 발생", e); return ResponseEntity.ok(RsData.of("500", "상품 상세 정보 조회 중 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } if (filtered.isEmpty()) { log.warn("조건에 맞는 상품이 없음 (UUID 재조회 단계)"); return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다", - new MatchResponse(List.of()))); + List.of())); } // 5. 매칭 스코어 계산 및 정렬 @@ -249,24 +248,24 @@ public ResponseEntity> matchProducts( log.error("❌ 매칭 스코어 계산 중 잘못된 입력: {}", e.getMessage()); return ResponseEntity.ok(RsData.of("400", "추천 계산 중 오류: " + e.getMessage(), - new MatchResponse(List.of()))); + List.of())); } catch (NullPointerException e) { log.error("❌ 매칭 스코어 계산 중 null 값 발견", e); return ResponseEntity.ok(RsData.of("500", "추천 계산 중 데이터 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } catch (Exception e) { log.error("❌ 매칭 스코어 계산 중 오류 발생", e); return ResponseEntity.ok(RsData.of("500", "추천 계산 중 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } if (ranked == null || ranked.isEmpty()) { log.warn("매칭 스코어 계산 결과가 비어있음"); return ResponseEntity.ok(RsData.of("200", "추천할 수 있는 상품이 없습니다", - new MatchResponse(List.of()))); + List.of())); } // 6. 상위 3개만 추출 (프론트 요구사항) @@ -277,14 +276,14 @@ public ResponseEntity> matchProducts( log.info("===== 최종 추천: {}개 상품 (고정) =====", topN.size()); - MatchResponse matchResponse = new MatchResponse(topN); - RsData result = RsData.of("200", + RsData> result = RsData.of("200", topN.size() + "개 상품을 추천했습니다", - matchResponse); + topN); log.info("🎉 응답 반환 준비 완료"); - log.info("Response: resultCode={}, msg={}, data.size={}", + log.info("✅ Response: resultCode={}, msg={}, data.size={}", result.resultCode(), result.msg(), topN.size()); + log.info("🔵 최종 응답 JSON 미리보기: {}", result); return ResponseEntity.ok(result); @@ -292,12 +291,12 @@ public ResponseEntity> matchProducts( log.error("❌ 잘못된 입력값 오류", e); return ResponseEntity.ok(RsData.of("400", "잘못된 요청입니다: " + e.getMessage(), - new MatchResponse(List.of()))); + List.of())); } catch (NullPointerException e) { log.error("❌ Null 값 참조 오류", e); return ResponseEntity.ok(RsData.of("500", "데이터 처리 중 오류가 발생했습니다", - new MatchResponse(List.of()))); + List.of())); } catch (Exception e) { log.error("❌❌❌ RecommendationController에서 예상치 못한 오류 발생 ❌❌❌", e); log.error("오류 타입: {}", e.getClass().getName()); @@ -305,7 +304,7 @@ public ResponseEntity> matchProducts( return ResponseEntity.ok(RsData.of("500", "추천 시스템에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.", - new MatchResponse(List.of()))); + List.of())); } } } From f45b341705ead8667bbdadc2c52e3d2990d42b97 Mon Sep 17 00:00:00 2001 From: yoostill Date: Sun, 19 Oct 2025 21:01:56 +0900 Subject: [PATCH 31/31] =?UTF-8?q?fix/405=20gpt=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/repository/ProductCustomRepositoryImpl.java | 3 +-- .../domain/product/product/repository/ProductRepository.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java index cd27c31c..7ea9684b 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java @@ -79,12 +79,11 @@ public ProductListResponse findProducts( )); } - // QueryDSL로 엔티티 조회 + THUMBNAIL join + // QueryDSL로 엔티티 조회 + 이미지 eager loading var query = queryFactory .select(p) .from(p) .leftJoin(p.images, img).fetchJoin() - .on(img.fileType.eq(FileType.THUMBNAIL)) .where(builder) .distinct(); diff --git a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java index 6995a29f..6f8b1568 100644 --- a/src/main/java/com/back/domain/product/product/repository/ProductRepository.java +++ b/src/main/java/com/back/domain/product/product/repository/ProductRepository.java @@ -19,10 +19,11 @@ public interface ProductRepository extends JpaRepository, ProductCustomRepository, JpaSpecificationExecutor { Optional findByProductUuid(UUID productUuid); - // 태그 정보를 포함한 상품 조회 (추천 시스템용) + // 태그 및 이미지 정보를 포함한 상품 조회 (추천 시스템용) @Query("SELECT DISTINCT p FROM Product p " + "LEFT JOIN FETCH p.productTags pt " + "LEFT JOIN FETCH pt.tag " + + "LEFT JOIN FETCH p.images " + "WHERE p.productUuid = :productUuid") Optional findByProductUuidWithTags(@Param("productUuid") UUID productUuid);