From 8c85b47791fb4a6d51035a6f54706e7a00d729ac Mon Sep 17 00:00:00 2001 From: yoostill Date: Tue, 14 Oct 2025 10:43:33 +0900 Subject: [PATCH 1/7] =?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 2/7] =?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 3/7] =?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 4/7] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=A9=94=EC=9D=B8=ED=98=84=ED=99=A9=20=ED=8C=94?= =?UTF-8?q?=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 5/7] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=A9=94=EC=9D=B8=ED=98=84=ED=99=A9=20=ED=8C=94?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=EC=88=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=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 6/7] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20=EC=9E=91=EA=B0=80=20?= =?UTF-8?q?=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 7/7] =?UTF-8?q?refactor/336=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20=EC=9E=91=EA=B0=80=20?= =?UTF-8?q?=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(