사용자 프로필 및 판매/구매 내역 기능 구현

뚜우웅이·2025년 4월 18일

캡스톤 디자인

목록 보기
11/35
post-thumbnail

Product

Entitiy


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "buyer_id")
    private User buyer;

    @Column
    private LocalDateTime soldDate; // 판매완료 날짜
    
    
        // 상품 판매완료 처리 메서드
    public void markAsSold(User buyer) {
        this.status = ProductStatus.SOLD_OUT;
        this.buyer = buyer;
        this.soldDate = LocalDateTime.now();
        this.stock = 0;
    }

    // 판매 취소 메서드 추가
    public void cancelSold() {
        this.status = ProductStatus.ACTIVE;
        this.buyer = null;
        this.soldDate = null;
        this.stock = 1; // 중고 상품은 보통 1개
    }

Repository

public interface ProductRepository extends JpaRepository<Product, Long>, ProductRepositoryCustom {
    // 판매자 ID와 상품 상태로 상품 목록 조회
    Page<Product> findBySellerIdAndStatus(Long sellerId, ProductStatus status, Pageable pageable);

    // 판매자 ID와 상품 상태로 상품 개수 조회
    long countBySellerIdAndStatus(Long sellerId, ProductStatus status);
    
    // 구매자 ID로 상품 목록 조회
    Page<Product> findByBuyerIdOrderBySoldDateDesc(Long buyerId, Pageable pageable);

    // 구매자 ID로 상품 개수 조회
    long countByBuyerId(Long buyerId);
}

단순한 페이징 및 정렬 기능은 QueryDSL 없이도 잘 작동한다.

Service

// 판매 완료 처리
    @Transactional
    public ProductDto.ProductResponse markProductAsSold(Long sellerId, Long productId, Long buyerId) {
        Product product = getProductWithSellerCheck(productId, sellerId);

        if (product.getStatus() == ProductStatus.SOLD_OUT) {
            throw new IllegalStateException("이미 판매 완료된 상품입니다.");
        }

        User buyer = userRepository.findById(buyerId)
                .orElseThrow(() -> new UserException.UserNotFoundException(buyerId));

        product.markAsSold(buyer);

        log.info("상품 판매완료 처리: 상품 ID {}, 판매자 ID {}, 구매자 ID {}", productId, sellerId, buyerId);

        return ProductDto.ProductResponse.from(product);
    }

    // 판매완료 취소 처리 메서드
    @Transactional
    public ProductDto.ProductResponse cancelProductSold(Long sellerId, Long productId) {
        Product product = getProductWithSellerCheck(productId, sellerId);

        if (product.getStatus() != ProductStatus.SOLD_OUT) {
            throw new IllegalStateException("판매 완료 상태가 아닌 상품은 취소할 수 없습니다.");
        }

        product.cancelSold();
        log.info("판매완료 취소 처리: 상품 ID {}, 판매자 ID {}", productId, sellerId);
        return ProductDto.ProductResponse.from(product);
    }

markProductAsSold

  • getProductWithSellerCheck()를 통해 상품을 조회하고 현재 사용자가 판매자인지 확인한다.
  • 상품이 이미 판매 완료 상태인지 확인하고, 그렇다면 예외를 발생시킨다.
    0 구매자 정보를 데이터베이스에서 조회하고, 존재하지 않으면 예외를 발생시킨다.
  • product.markAsSold(buyer)를 호출하여 상품을 판매 완료 상태로 변경하고 구매자를 설정한다.

cancelProductSold

  • getProductWithSellerCheck()를 통해 상품을 조회하고 현재 사용자가 판매자인지 확인한다.
  • 상품이 판매 완료(SOLD_OUT) 상태가 아니면 예외를 발생시킨다.
  • product.cancelSold()를 호출하여 상품을 다시 판매 중 상태로 변경한다.

Controller

@Operation(summary = "상품 판매완료 처리", description = "상품을 판매완료 상태로 변경하고 구매자를 지정합니다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "판매완료 처리 성공"),
            @ApiResponse(responseCode = "400", description = "잘못된 요청"),
            @ApiResponse(responseCode = "401", description = "인증 실패"),
            @ApiResponse(responseCode = "403", description = "권한 없음"),
            @ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음")
    })
    @PostMapping("/{productId}/sold")
    public ResponseEntity<ResponseDTO<ProductDto.ProductResponse>> markProductAsSold(
            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
            @Parameter(description = "상품 ID", required = true) @PathVariable Long productId,
            @Parameter(description = "구매자 ID", required = true) @RequestParam Long buyerId) {

        log.info("상품 판매완료 처리 요청: 상품 ID {}, 판매자 ID {}, 구매자 ID {}",
                productId, userDetails.getUserId(), buyerId);

        ProductDto.ProductResponse response = productService.markProductAsSold(
                userDetails.getUserId(), productId, buyerId);

        return ResponseEntity.ok(ResponseDTO.success(response, "상품이 판매완료 처리되었습니다."));
    }

    @Operation(summary = "판매완료 취소", description = "판매완료 상태의 상품을 다시 판매중 상태로 변경합니다.")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "판매완료 취소 성공"),
            @ApiResponse(responseCode = "400", description = "잘못된 요청"),
            @ApiResponse(responseCode = "401", description = "인증 실패"),
            @ApiResponse(responseCode = "403", description = "권한 없음"),
            @ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음")
    })
    @PostMapping("/{productId}/cancel-sold")
    public ResponseEntity<ResponseDTO<ProductDto.ProductResponse>> cancelProductSold(
            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
            @Parameter(description = "상품 ID", required = true) @PathVariable Long productId) {

        log.info("판매완료 취소 요청: 상품 ID {}, 판매자 ID {}", productId, userDetails.getUserId());

        ProductDto.ProductResponse response = productService.cancelProductSold(
                userDetails.getUserId(), productId);

        return ResponseEntity.ok(ResponseDTO.success(response, "판매완료가 취소되었습니다."));
    }

상품 판매 완료 처리 시 @RequestParam Long buyerId: 구매자 ID (쿼리 파라미터)을 입력해줘야 한다.

UserProfile

DTO

public class UserProfileDto {

    // 판매 상품 내역 DTO
    @Builder
    public record SellingProductResponse(
            Long productId,
            String name,
            Long price,
            String category,
            String status,
            String thumbnailUrl,
            String buyerName,
            LocalDateTime soldDate,
            LocalDateTime createdDate
    ) {}

    // 판매 내역 페이지 응답 DTO
    @Builder
    public record SellingHistoryResponse(
        List<SellingProductResponse> activeProducts,
        List<SellingProductResponse> soldProducts,
        int totalProductCount
    ) {}

    // 구매 내역 DTO
    @Builder
    public record PurchaseItem(
        Long productId,
        String ProductName,
        Long price,
        String category,
        String thumbnailUrl,
        String sellerName,
        LocalDateTime purchaseDate
    ) {}

    // 구매 내역 페이지 응답 DTO
    @Builder
    public record PurchaseHistoryResponse(
            List<PurchaseItem> purchases,
            int totalPurchaseCount
    ) {}

    // 프로필 요약 정보 DTO
    @Builder
    public record ProfileSummaryResponse(
            Long userId,
            String email,
            String name,
            int totalSellingCount,
            int totalSoldCount,
            int totalPurchaseCount,
            double averageRating,
            LocalDateTime joinDate
    ) {}
}

SellingProductResponse
판매 중인 또는 판매된 단일 상품 정보를 담는 DTO

  • 필드:
    • productId: 상품 ID
    • name: 상품명
    • price: 가격
    • category: 카테고리
    • status: 상품 상태
    • thumbnailUrl: 썸네일 이미지 URL
    • buyerName: 구매자 이름 (판매된 상품인 경우)
    • soldDate: 판매 완료 날짜 (판매된 상품인 경우)
    • createdDate: 상품 등록 날짜

SellingHistoryResponse
사용자의 전체 판매 내역을 담는 DTO

  • 필드:
    • activeProducts: 현재 판매 중인 상품 목록
    • soldProducts: 판매 완료된 상품 목록
    • totalProductCount: 총 상품 수

PurchaseItem
구매한 단일 상품 정보를 담는 DTO

  • 필드:
    • productId: 상품 ID
    • productName으로: 상품명
    • price: 가격
    • category: 카테고리
    • thumbnailUrl: 썸네일 이미지 URL
    • sellerName: 판매자 이름
    • purchaseDate: 구매 날짜

PurchaseHistoryResponse
사용자의 전체 구매 내역을 담는 DTO

  • 필드:
    • purchases: 구매한 상품 목록
    • totalPurchaseCount: 총 구매 수

ProfileSummaryResponse
사용자 프로필의 요약 정보를 담는 DTO

  • 필드:
    • userId: 사용자 ID
    • email: 이메일
    • name: 이름
    • totalSellingCount: 총 판매 중인 상품 수
    • totalSoldCount: 총 판매 완료된 상품 수
    • totalPurchaseCount: 총 구매 상품 수
    • averageRating: 평균 평점
    • joinDate: 가입 날짜

Service

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserProfileService {

    private final UserRepository userRepository;
    private final ProductRepository productRepository;

    // 사용자의 프로필 요약 정보를 조회
    public UserProfileDto.ProfileSummaryResponse getProfileSummary(Long userId) {
        User user = getUser(userId);

        // 판매 중인 상품 수
        long activeProductCount = productRepository.countBySellerIdAndStatus(userId, ProductStatus.ACTIVE);

        // 판매 완료된 상품 수
        long soldProductCount = productRepository.countBySellerIdAndStatus(userId, ProductStatus.SOLD_OUT);

        // 구매한 상품 수
        long purchaseCount = productRepository.countByBuyerId(userId);

        // TODO: 평균 평점 계산 로직
        double averageRating = 0.0;

        return UserProfileDto.ProfileSummaryResponse.builder()
                .userId(user.getId())
                .email(user.getEmail())
                .name(user.getName())
                .totalSellingCount((int) activeProductCount)
                .totalSoldCount((int) soldProductCount)
                .totalPurchaseCount((int) purchaseCount)
                .averageRating(averageRating)
                .joinDate(user.getCreatedDate())
                .build();
    }

    // 사용자의 판매 상품 내역을 조회
    public UserProfileDto.SellingHistoryResponse getSellingHistory(Long userId, Pageable pageable) {
        // 판매중인 상품 조회
        Page<Product> activeProductsPage = productRepository.findBySellerIdAndStatus(userId, ProductStatus.ACTIVE, pageable);

        // 판매 완료된 상품 조회
        Page<Product> soldProductsPage = productRepository.findBySellerIdAndStatus(userId, ProductStatus.SOLD_OUT, pageable);

        // DTO 변환
        List<UserProfileDto.SellingProductResponse> activeProductResponses =
                activeProductsPage.stream().map(this::mapToSellingProductResponse).collect(Collectors.toList());

        List<UserProfileDto.SellingProductResponse> soldProductResponses =
                soldProductsPage.stream().map(this::mapToSellingProductResponse).collect(Collectors.toList());

        return UserProfileDto.SellingHistoryResponse.builder()
                .activeProducts(activeProductResponses)
                .soldProducts(soldProductResponses)
                .totalProductCount((int) (activeProductsPage.getTotalElements() +
                        soldProductsPage.getTotalElements()))
                .build();

    }

    // 사용자의 구매 내역 조회 (Product 엔터티의 buyer 필드 기준)
    public UserProfileDto.PurchaseHistoryResponse getPurchaseHistory(Long userId, Pageable pageable) {
        // 사용자가 구매한 상품 목록 조회 (buyer_id 기준)
        Page<Product> purchasedProductsPage = productRepository.findByBuyerIdOrderBySoldDateDesc(userId, pageable);

        // DTO 변환
        List<UserProfileDto.PurchaseItem> purchaseItems = purchasedProductsPage.getContent().stream()
                .map(this::mapToPurchaseItem)
                .collect(Collectors.toList());

        return UserProfileDto.PurchaseHistoryResponse.builder()
                .purchases(purchaseItems)
                .totalPurchaseCount((int) purchasedProductsPage.getTotalElements())
                .build();
    }

    // Product 엔터티를 SellingProductResponse DTO로 변환
    private UserProfileDto.SellingProductResponse mapToSellingProductResponse(Product product) {
        String buyerName = product.getBuyer() != null ? product.getBuyer().getName() : null;
        return UserProfileDto.SellingProductResponse.builder()
                .productId(product.getId())
                .name(product.getName())
                .price(product.getPrice())
                .category(product.getCategory().getDisplayName())
                .status(product.getStatus().getDisplayName())
                .thumbnailUrl(product.getRepresentativeThumbnailUrl())
                // .viewCount(0) // TODO: 조회수 구현 시 추가
                // .likeCount(0) // TODO: 찜하기 기능 구현 시 추가
                .buyerName(buyerName)
                .soldDate(product.getSoldDate())
                .createdDate(product.getCreatedDate())
                .build();
    }

    // 구매한 상품을 PurchaseHistoryItem DTO로 변환
    private UserProfileDto.PurchaseItem mapToPurchaseItem(Product product) {
        return UserProfileDto.PurchaseItem.builder()
                .productId(product.getId())
                .productName(product.getName())
                .price(product.getPrice())
                .category(product.getCategory().getDisplayName())
                .thumbnailUrl(product.getRepresentativeThumbnailUrl())
                .sellerName(product.getSeller().getName())
                .purchaseDate(product.getSoldDate())
                .build();
    }

    private User getUser(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new UserException.UserNotFoundException(userId));
    }
}

주요 기능

  • 프로필 요약 정보 조회 (getProfileSummary)
    사용자의 기본 정보와 요약 통계를 제공

  • 판매 내역 조회 (getSellingHistory)
    사용자가 판매 중이거나 판매 완료한 상품 목록을 조회

  • 구매 내역 조회 (getPurchaseHistory)
    사용자가 구매한 상품 목록을 조회

유틸리티 메서드

  • mapToSellingProductResponse
    Product 엔티티를 SellingProductResponse DTO로 변환하는 메서드

    • 상품의 구매자가 있으면 구매자 이름을 설정
    • 상품 상태와 카테고리를 표시 이름(displayName)으로 변환
  • mapToPurchaseItem
    Product 엔티티를 PurchaseItem DTO로 변환하는 메서드

    • 구매한 상품의 정보를 DTO 형태로 변환
    • 판매자 이름과 같은 연관 정보도 포함

Controller

@Slf4j
@RestController
@RequestMapping("/api/users/profile")
@RequiredArgsConstructor
@Tag(name = "사용자 프로필", description = "사용자 프로필 관련 API")
public class UserProfileController {

    private final UserProfileService userProfileService;

    @Operation(summary = "프로필 요약 정보 조회", description = "로그인한 사용자의 프로필 요약 정보를 조회합니다.")
    @ApiResponse(responseCode = "200", description = "프로필 요약 정보 조회 성공")
    @GetMapping("/summary")
    public ResponseEntity<ResponseDTO<UserProfileDto.ProfileSummaryResponse>> getProfileSummary(
            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails) {

        log.info("프로필 요약 정보 조회: 사용자 ID {}", userDetails.getUserId());

        UserProfileDto.ProfileSummaryResponse response = userProfileService.getProfileSummary(userDetails.getUserId());

        return ResponseEntity.ok(ResponseDTO.success(response));
    }

    @Operation(summary = "판매 내역 조회", description = "로그인한 사용자의 판매 내역을 조회합니다.")
    @ApiResponse(responseCode = "200", description = "판매 내역 조회 성공")
    @GetMapping("/selling")
    public ResponseEntity<ResponseDTO<UserProfileDto.SellingHistoryResponse>> getSellingHistory(
            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
            @Parameter(description = "페이지네이션 정보")
            @PageableDefault(size = 10) Pageable pageable) {

        log.info("판매 내역 조회: 사용자 ID {}", userDetails.getUserId());
        UserProfileDto.SellingHistoryResponse response = userProfileService.getSellingHistory(userDetails.getUserId(), pageable);

        return ResponseEntity.ok(ResponseDTO.success(response));
    }

    @Operation(summary = "구매 내역 조회", description = "로그인한 사용자의 구매 내역을 조회합니다.")
    @ApiResponse(responseCode = "200", description = "구매 내역 조회 성공")
    @GetMapping("/me/purchases")
    public ResponseEntity<ResponseDTO<UserProfileDto.PurchaseHistoryResponse>> getPurchaseHistory(
            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
            @Parameter(description = "페이지네이션 정보")
            @PageableDefault(size = 10) Pageable pageable) {

        log.info("구매 내역 조회: 사용자 ID {}", userDetails.getUserId());
        UserProfileDto.PurchaseHistoryResponse response =
                userProfileService.getPurchaseHistory(userDetails.getUserId(), pageable);

        return ResponseEntity.ok(ResponseDTO.success(response));
    }

    @Operation(summary = "다른 사용자 프로필 조회", description = "다른 사용자의 프로필 정보를 조회합니다. (제한된 정보만 제공)")
    @ApiResponse(responseCode = "200", description = "사용자 프로필 조회 성공")
    @GetMapping("/{userId}")
    public ResponseEntity<ResponseDTO<UserProfileDto.ProfileSummaryResponse>> getUserProfile(
            @Parameter(description = "조회할 사용자 ID", required = true) @PathVariable Long userId) {

        log.info("사용자 프로필 조회: 사용자 ID {}", userId);
        UserProfileDto.ProfileSummaryResponse response = userProfileService.getProfileSummary(userId);

        return ResponseEntity.ok(ResponseDTO.success(response));
    }
}

주요 API 엔드포인트

  • 프로필 요약 정보 조회 (GET /api/users/profile/summary)

    • 인증된 현재 사용자의 프로필 요약 정보를 조회한다.
    • 사용자 ID, 이메일, 이름, 판매/구매 통계 등 기본 정보 제공한다.
    • 인증된 사용자만 접근 가능하며, 사용자 정보는 CustomUserDetails에서 추출
  • 판매 내역 조회 (GET /api/users/profile/selling)

    • 인증된 현재 사용자의 판매 내역을 조회한다.
    • 판매 중인 상품과 판매 완료된 상품 목록 제공한다.
    • 페이징 처리 가능 (기본 페이지 크기: 10)
  • 구매 내역 조회 (GET /api/users/profile/me/purchases)

    • 인증된 현재 사용자의 구매 내역을 조회한다.
    • 구매한 상품 목록과 총 구매 수 제공한다.
    • 페이징 처리 가능 (기본 페이지 크기: 10)
  • 다른 사용자 프로필 조회 (GET /api/users/profile/{userId})

    • 특정 사용자의 프로필 정보를 조회합니다.
    • URL 경로에 사용자 ID를 지정하여 요청
    • 인증이 필요 없는 공개 API로 보임 (인증 정보를 파라미터로 받지 않음)

테스트

판매자 id는 1
구매자 id는 2

상품 등록 후 판매자 판매 내역 조회

상품 판매

판매 후 판매자 프로필 요약 정보

판매자 판매 내역 조회

구매자로 로그인 후 구매 내역 조회

판매완료 취소

판매자 확인

다시 판매자로 로그인을 해서 판매완료 취소 요청을 보낸다.

구매자 확인

구매자로 로그인하여 확인한다.

profile
공부하는 초보 개발자

0개의 댓글