diff --git a/build.gradle b/build.gradle index 37f9c2961..1ac57d989 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { implementation 'p6spy:p6spy:3.9.1' implementation 'com.github.gavlyukovskiy:datasource-decorator-spring-boot-autoconfigure:1.9.0' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index b41d2c488..a82f3f311 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,3 +59,33 @@ services: restart: always ports: - "6379:6379" + prometheus: + image: prom/prometheus + container_name: prometheus + restart: always + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + grafana: + image: grafana/grafana + container_name: grafana + restart: always + ports: + - "3009:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} + volumes: + - grafana-storage:/var/lib/grafana + + mysqld_exporter: + image: prom/mysqld_exporter + restart: always + environment: + - DATA_SOURCE_NAME=${MYSQL_USERNAME}:${MYSQL_PASSWORD}@tcp(${MYSQL_HOST})/ + ports: + - "9104:9104" + +volumes: + grafana-storage: \ No newline at end of file diff --git a/nginx.conf b/nginx.conf index b9f436a4f..2512fd8bf 100644 --- a/nginx.conf +++ b/nginx.conf @@ -64,6 +64,30 @@ http { ssl_dhparam /etc/ssl/certs/ssl-dhparams.pem; } + server { + listen 9090; + + location / { + proxy_pass http://prometheus:9090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + server { + listen 3009; + + location / { + proxy_pass http://grafana:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + server { if ($host = www.fundina.shop) { return 301 https://$host$request_uri; diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 000000000..ecb803232 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'spring-boot-apps' + static_configs: + - targets: ['app1:8080', 'app2:8080', 'app3:8080'] + + - job_name: 'mysql' + static_configs: + - targets: ['mysqld_exporter:9104'] \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/common/config/SecurityConfig.java b/src/main/java/org/kakaoshare/backend/common/config/SecurityConfig.java index 5636f408b..dcb93c448 100644 --- a/src/main/java/org/kakaoshare/backend/common/config/SecurityConfig.java +++ b/src/main/java/org/kakaoshare/backend/common/config/SecurityConfig.java @@ -26,10 +26,13 @@ public class SecurityConfig { private static final List ORIGIN_PATTERN = List.of("https://www.kakaofunding.kro.kr/"); private static final String CORS_CONFIGURATION_PATTERN = "/**"; - private static final String API_V_1 = "/api/v1/"; + public static final String API_V_1 = "/api/v1/"; + private static final String ACTUATOR = "/actuator/**"; + private static final List ALLOWED_HEADERS = Arrays.asList("Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"); private static final List ALLOWED_METHODS = Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"); - + private static final String METRICS = "/metrics"; + private final AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; @@ -39,6 +42,8 @@ public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception return http.authorizeHttpRequests( authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .requestMatchers(ACTUATOR).permitAll() + .requestMatchers(METRICS).permitAll() .requestMatchers(API_V_1 + "oauth/login").permitAll() .requestMatchers(API_V_1 + "oauth/logout").authenticated() .requestMatchers(API_V_1 + "oauth/reissue").permitAll() diff --git a/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoApiErrorCode.java b/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoApiErrorCode.java index 6b0c86302..0e621e5dc 100644 --- a/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoApiErrorCode.java +++ b/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoApiErrorCode.java @@ -12,7 +12,7 @@ @Getter public enum KakaoApiErrorCode implements ErrorCode { - INVALID_ACCESS_TOKEN(-401, CODE_PREFIX + "007", HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 엑세스 토큰입니다."); + INVALID_ACCESS_TOKEN(-401, CODE_PREFIX + "008", HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 엑세스 토큰입니다."); private final int serverErrorCode; private final String code; diff --git a/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoAuthErrorCode.java b/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoAuthErrorCode.java index ad4907d7b..47264389a 100644 --- a/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoAuthErrorCode.java +++ b/src/main/java/org/kakaoshare/backend/common/error/kakao/code/KakaoAuthErrorCode.java @@ -12,8 +12,8 @@ @Getter public enum KakaoAuthErrorCode implements ErrorCode { - NOT_FOUND_REFRESH_TOKEN("KOE319", CODE_PREFIX + "008", HttpStatus.NOT_FOUND, "소셜 리프레시 토큰을 찾을 수 없습니다."), - INVALID_REFRESH_TOKEN("KOE322", CODE_PREFIX + "009", HttpStatus.NOT_FOUND, "이미 만료되었거나 유효하지 않은 소셜 리프레시 토큰입니다."); + NOT_FOUND_REFRESH_TOKEN("KOE319", CODE_PREFIX + "009", HttpStatus.NOT_FOUND, "소셜 리프레시 토큰을 찾을 수 없습니다."), + INVALID_REFRESH_TOKEN("KOE322", CODE_PREFIX + "010", HttpStatus.NOT_FOUND, "이미 만료되었거나 유효하지 않은 소셜 리프레시 토큰입니다."); private final String serverErrorCode; private final String code; diff --git a/src/main/java/org/kakaoshare/backend/domain/member/exception/token/RefreshTokenErrorCode.java b/src/main/java/org/kakaoshare/backend/domain/member/exception/token/RefreshTokenErrorCode.java index 52f23702f..00cb19b70 100644 --- a/src/main/java/org/kakaoshare/backend/domain/member/exception/token/RefreshTokenErrorCode.java +++ b/src/main/java/org/kakaoshare/backend/domain/member/exception/token/RefreshTokenErrorCode.java @@ -6,9 +6,9 @@ @Getter public enum RefreshTokenErrorCode implements ErrorCode { - NOT_FOUND(CODE_PREFIX + "004", HttpStatus.NOT_FOUND, "리프레시 토큰을 찾을 수 없습니다."), - INVALID(CODE_PREFIX + "005", HttpStatus.NOT_FOUND, "유효하지 않은 리프레시 토큰입니다."), - EXPIRED(CODE_PREFIX + "006", HttpStatus.NOT_FOUND, "만료된 리프레시 토큰입니다."); + NOT_FOUND(CODE_PREFIX + "005", HttpStatus.NOT_FOUND, "리프레시 토큰을 찾을 수 없습니다."), + INVALID(CODE_PREFIX + "006", HttpStatus.BAD_REQUEST, "유효하지 않은 리프레시 토큰입니다."), + EXPIRED(CODE_PREFIX + "007", HttpStatus.BAD_REQUEST, "만료된 리프레시 토큰입니다."); private final String code; private final HttpStatus httpStatus; diff --git a/src/main/java/org/kakaoshare/backend/domain/order/repository/query/OrderRepositoryCustomImpl.java b/src/main/java/org/kakaoshare/backend/domain/order/repository/query/OrderRepositoryCustomImpl.java index 252958fb0..90a2a54d1 100644 --- a/src/main/java/org/kakaoshare/backend/domain/order/repository/query/OrderRepositoryCustomImpl.java +++ b/src/main/java/org/kakaoshare/backend/domain/order/repository/query/OrderRepositoryCustomImpl.java @@ -1,36 +1,24 @@ package org.kakaoshare.backend.domain.order.repository.query; -import static org.kakaoshare.backend.common.util.RepositoryUtils.priceExpression; -import static org.kakaoshare.backend.domain.member.entity.QMember.member; -import static org.kakaoshare.backend.domain.order.entity.QOrder.order; -import static org.kakaoshare.backend.domain.product.entity.QProduct.product; -import static org.kakaoshare.backend.domain.receipt.entity.QReceipt.receipt; -import static org.kakaoshare.backend.domain.wish.entity.QWish.wish; - import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; - -import java.time.LocalDateTime; -import java.util.List; import lombok.RequiredArgsConstructor; import org.kakaoshare.backend.common.util.RepositoryUtils; import org.kakaoshare.backend.common.vo.PriceRange; import org.kakaoshare.backend.domain.member.entity.Gender; -import org.kakaoshare.backend.domain.rank.dto.RankPriceRange; - -import lombok.RequiredArgsConstructor; -import org.kakaoshare.backend.common.util.RepositoryUtils; +import org.kakaoshare.backend.domain.member.entity.QMember; import org.kakaoshare.backend.domain.option.dto.QOptionSummaryResponse; import org.kakaoshare.backend.domain.order.dto.inquiry.OrderHistoryDetailDto; import org.kakaoshare.backend.domain.order.dto.inquiry.OrderProductDto; import org.kakaoshare.backend.domain.order.dto.inquiry.QOrderProductDto; import org.kakaoshare.backend.domain.order.vo.OrderHistoryDate; import org.kakaoshare.backend.domain.product.dto.QProductDto; +import org.kakaoshare.backend.domain.rank.dto.RankPriceRange; import org.kakaoshare.backend.domain.rank.dto.RankResponse; import org.kakaoshare.backend.domain.rank.util.TargetType; import org.springframework.data.domain.Page; @@ -38,18 +26,27 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; -import static org.kakaoshare.backend.common.util.RepositoryUtils.*; +import static org.kakaoshare.backend.common.util.RepositoryUtils.createOrderSpecifiers; +import static org.kakaoshare.backend.common.util.RepositoryUtils.eqExpression; +import static org.kakaoshare.backend.common.util.RepositoryUtils.periodExpression; +import static org.kakaoshare.backend.common.util.RepositoryUtils.priceExpression; +import static org.kakaoshare.backend.common.util.RepositoryUtils.toPage; import static org.kakaoshare.backend.domain.member.entity.QMember.member; import static org.kakaoshare.backend.domain.order.entity.QOrder.order; import static org.kakaoshare.backend.domain.product.entity.QProduct.product; import static org.kakaoshare.backend.domain.receipt.entity.QReceipt.receipt; import static org.kakaoshare.backend.domain.receipt.entity.QReceiptOption.receiptOption; +import static org.kakaoshare.backend.domain.wish.entity.QWish.wish; @Component @RequiredArgsConstructor public class OrderRepositoryCustomImpl implements OrderRepositoryCustom { + private static final QMember receiver = new QMember("receiver"); + private static final QMember recipient = new QMember("recipient"); + private final JPAQueryFactory queryFactory; public Page findTopRankedProductsByOrders(LocalDateTime term, Pageable pageable) { @@ -162,10 +159,11 @@ private JPAQuery createOrderProductDtoBaseQuery(final String providerId, fina return queryFactory.from(order) .innerJoin(order.receipt, receipt) .innerJoin(receipt.product, product) - .innerJoin(receipt.receiver, member) + .innerJoin(receipt.recipient, recipient) + .innerJoin(receipt.receiver, receiver) .where( periodExpression(receipt.createdAt, date), - eqExpression(member.providerId, providerId) + eqExpression(recipient.providerId, providerId) ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()); @@ -186,7 +184,7 @@ private QOrderProductDto getOrderProductDto() { return new QOrderProductDto( order.ordersId, receipt.orderNumber, - member.name, + receiver.name, getProductDto(), receipt.quantity, order.createdAt, diff --git a/src/main/java/org/kakaoshare/backend/domain/product/controller/ProductController.java b/src/main/java/org/kakaoshare/backend/domain/product/controller/ProductController.java index 36a0c8a5b..2c6d8a32d 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/controller/ProductController.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/controller/ProductController.java @@ -29,21 +29,22 @@ public class ProductController { public static final int PAGE_DEFAULT_SIZE = 20; private final ProductService productService; - + @GetMapping("/{productId}") public ResponseEntity getProductDetail(@PathVariable Long productId, + @Nullable @LoggedInMember String providerId, @RequestParam(name = "tab", required = false, defaultValue = "description") String tab) { if ("description".equals(tab)) { - DescriptionResponse response = productService.getProductDescription(productId); + DescriptionResponse response = productService.getProductDescription(productId, providerId); return ResponseEntity.ok(response); } if ("detail".equals(tab)) { - DetailResponse response = productService.getProductDetail(productId); + DetailResponse response = productService.getProductDetail(productId, providerId); return ResponseEntity.ok(response); } return ResponseEntity.badRequest().body("Invalid tab value"); } - + @GetMapping public ResponseEntity getSimpleProductsInPage( @Nullable @LoggedInMember String providerId, @@ -52,15 +53,15 @@ public ResponseEntity getSimpleProductsInPage( PageResponse simpleProductsPage = productService.getSimpleProductsPage(categoryId, pageable, providerId); return ResponseEntity.ok(simpleProductsPage); } - + @GetMapping("/brands/{brandId}") public ResponseEntity getBrandsProducts(@PathVariable("brandId") Long brandId, @PageableDefault(size = PAGE_DEFAULT_SIZE) Pageable pageable) { PageResponse simpleProductPage = productService.getSimpleProductsByBrandId(brandId, pageable); return ResponseEntity.ok(simpleProductPage); } - - + + @PostMapping("/{productId}/wishes") public ResponseEntity resistWishingProduct(@LoggedInMember String providerId, @PathVariable("productId") Long productId, @@ -68,8 +69,8 @@ public ResponseEntity resistWishingProduct(@LoggedInMember String providerId, WishResponse response = productService.resisterProductInWishList(providerId, productId, type); return ResponseEntity.status(HttpStatus.CREATED).body(response); } - - + + @DeleteMapping("/{productId}/wishes") public ResponseEntity cancelWisingProduct(@LoggedInMember String providerId, @PathVariable("productId") Long productId) { diff --git a/src/main/java/org/kakaoshare/backend/domain/product/dto/DescriptionResponse.java b/src/main/java/org/kakaoshare/backend/domain/product/dto/DescriptionResponse.java index 9851b39a0..e38feb811 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/dto/DescriptionResponse.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/dto/DescriptionResponse.java @@ -9,6 +9,7 @@ import org.kakaoshare.backend.domain.product.entity.ProductThumbnail; import java.util.List; +import org.kakaoshare.backend.domain.wish.entity.Wish; @Getter @Builder @@ -25,9 +26,11 @@ public class DescriptionResponse { private final String brandName; private final Long brandId; private final String brandThumbnail; + private final int wishCount; + private final boolean isWish; public static DescriptionResponse of(final Product product, List descriptionPhotosUrls, - List optionsResponses, List productThumbnailsUrls) { + List optionsResponses, List productThumbnailsUrls, Boolean isWished) { List thumbnails; if (product.getProductThumbnails().isEmpty() && product.getPhoto() != null) { @@ -51,6 +54,8 @@ public static DescriptionResponse of(final Product product, List descrip .brandName(product.getBrand().getName()) .brandId(product.getBrand().getBrandId()) .brandThumbnail(product.getBrand().getIconPhoto()) + .wishCount(product.getWishCount()) + .isWish(isWished) .build(); } } diff --git a/src/main/java/org/kakaoshare/backend/domain/product/dto/DetailResponse.java b/src/main/java/org/kakaoshare/backend/domain/product/dto/DetailResponse.java index 3748c39c1..bd25463ee 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/dto/DetailResponse.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/dto/DetailResponse.java @@ -1,9 +1,11 @@ package org.kakaoshare.backend.domain.product.dto; +import java.util.Optional; import lombok.Builder; import lombok.Getter; import org.kakaoshare.backend.domain.option.dto.OptionResponse; import org.kakaoshare.backend.domain.product.entity.Product; +import org.kakaoshare.backend.domain.product.entity.ProductDetail; import org.kakaoshare.backend.domain.product.entity.ProductThumbnail; import java.util.List; @@ -26,13 +28,18 @@ public class DetailResponse { private final String billingNotice; private final String caution; private final List productThumbnails; - public static DetailResponse of(final Product product,List optionsResponses) { - String origin = product.getProductDetail() != null ? product.getProductDetail().getOrigin() : "정보 없음"; - String manufacturer = product.getProductDetail() != null ? product.getProductDetail().getManufacturer() : "정보 없음"; - String tel = product.getProductDetail() != null ? product.getProductDetail().getTel() : "정보 없음"; - String deliverDescription = product.getProductDetail() != null ? product.getProductDetail().getDeliverDescription() : "정보 없음"; - String billingNotice = product.getProductDetail() != null ? product.getProductDetail().getBillingNotice() : "정보 없음"; - String caution = product.getProductDetail() != null ? product.getProductDetail().getCaution() : "정보 없음"; + private final int wishCount; + private final boolean isWish; + + public static DetailResponse of(final Product product, List optionsResponses, Boolean isWished) { + ProductDetail detail = product.getProductDetail(); + + String origin = Optional.ofNullable(detail).map(ProductDetail::getOrigin).orElse(null); + String manufacturer = Optional.ofNullable(detail).map(ProductDetail::getManufacturer).orElse(null); + String tel = Optional.ofNullable(detail).map(ProductDetail::getTel).orElse(null); + String deliverDescription = Optional.ofNullable(detail).map(ProductDetail::getDeliverDescription).orElse(null); + String billingNotice = Optional.ofNullable(detail).map(ProductDetail::getBillingNotice).orElse(null); + String caution = Optional.ofNullable(detail).map(ProductDetail::getCaution).orElse(null); return DetailResponse.builder() .productId(product.getProductId()) @@ -51,6 +58,8 @@ public static DetailResponse of(final Product product,List optio .brandName(product.getBrandName()) .brandId(product.getBrand().getBrandId()) .brandThumbnail(product.getBrand().getIconPhoto()) + .wishCount(product.getWishCount()) + .isWish(isWished) .build(); } } diff --git a/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustom.java b/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustom.java index eccc79657..a74530cae 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustom.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustom.java @@ -1,9 +1,11 @@ package org.kakaoshare.backend.domain.product.repository.query; +import org.kakaoshare.backend.domain.member.entity.Member; import org.kakaoshare.backend.domain.product.dto.DescriptionResponse; import org.kakaoshare.backend.domain.product.dto.DetailResponse; import org.kakaoshare.backend.domain.product.dto.Product4DisplayDto; import org.kakaoshare.backend.domain.product.dto.ProductDto; +import org.kakaoshare.backend.domain.product.entity.Product; import org.kakaoshare.backend.domain.search.dto.SimpleBrandProductDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,8 +17,11 @@ public interface ProductRepositoryCustom { Page findAllByCategoryId(Long categoryId, Pageable pageable, final String providerId); Page findAllByBrandId(final Long brandId, final Pageable pageable); Page findAllByProductIds(final List productIds, final Pageable pageable); - DescriptionResponse findProductWithDetailsAndPhotos(Long productId); - DetailResponse findProductDetail(Long productId); + DescriptionResponse findProductWithDetailsAndPhotosWithoutMember(Product product); + DescriptionResponse findProductWithDetailsAndPhotosWithMember(Product product, Member member); + DetailResponse findProductDetailWithoutMember(Product product); + DetailResponse findProductDetailWithMember(Product product, Member member); + Product findProductById(Long productId); Page findBySearchConditions(final String keyword, final Integer minPrice, final Integer maxPrice, final List categories, final Pageable pageable,final String providerId); Page findBySearchConditionsGroupByBrand(String keyword, diff --git a/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustomImpl.java b/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustomImpl.java index e2cafb7d2..ed7ffd0ac 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustomImpl.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/repository/query/ProductRepositoryCustomImpl.java @@ -8,6 +8,7 @@ import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import io.micrometer.common.lang.Nullable; import lombok.RequiredArgsConstructor; import org.kakaoshare.backend.common.error.GlobalErrorCode; import org.kakaoshare.backend.common.error.exception.BusinessException; @@ -15,6 +16,7 @@ import org.kakaoshare.backend.common.util.sort.SortableRepository; import org.kakaoshare.backend.domain.brand.dto.QSimpleBrandDto; import org.kakaoshare.backend.domain.brand.dto.SimpleBrandDto; +import org.kakaoshare.backend.domain.member.entity.Member; import org.kakaoshare.backend.domain.option.dto.OptionResponse; import org.kakaoshare.backend.domain.option.dto.ProductOptionDetailResponse; import org.kakaoshare.backend.domain.option.entity.QOption; @@ -31,6 +33,8 @@ import org.kakaoshare.backend.domain.product.entity.QProductThumbnail; import org.kakaoshare.backend.domain.search.dto.QSimpleBrandProductDto; import org.kakaoshare.backend.domain.search.dto.SimpleBrandProductDto; +import org.kakaoshare.backend.domain.wish.entity.QWish; +import org.kakaoshare.backend.domain.wish.entity.Wish; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -52,14 +56,14 @@ @RequiredArgsConstructor public class ProductRepositoryCustomImpl implements ProductRepositoryCustom, SortableRepository { private static final int PRODUCT_SIZE_GROUP_BY_BRAND = 9; - + private final JPAQueryFactory queryFactory; - + @Override public Page findAllByCategoryId(final Long categoryId, final Pageable pageable, final String providerId) { - + JPAQuery contentQuery = queryFactory .select(getProduct4DisplayDto(providerId)) .from(product) @@ -70,7 +74,7 @@ public Page findAllByCategoryId(final Long categoryId, JPAQuery countQuery = countProduct(categoryId); return toPage(pageable, contentQuery, countQuery); } - + @Override public Page findAllByBrandId(final Long brandId, final Pageable pageable) { @@ -82,26 +86,26 @@ public Page findAllByBrandId(final Long brandId, .orderBy(getOrderSpecifiers(pageable)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()); - + JPAQuery countQuery = countBrand(brandId); return toPage(pageable, contentQuery, countQuery); } - + @Override public Page findAllByProductIds(final List productIds, final Pageable pageable) { final JPAQuery countQuery = queryFactory.select(product.productId.count()) .from(product) .where(containsExpression(product.productId, productIds)); - + final JPAQuery contentQuery = queryFactory.select(getProductDto()) .from(product) .where(containsExpression(product.productId, productIds)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()); - + return toPage(pageable, contentQuery, countQuery); } - + @Override public Page findBySearchConditions(final String keyword, final Integer minPrice, @@ -116,7 +120,7 @@ public Page findBySearchConditions(final String keyword, containsExpression(product.price, minPrice, maxPrice) // containsExpression(category.name, categories) ); - + // TODO: 3/19/24 카테고리 필터링은 추후 구현 예정 final JPAQuery contentQuery = queryFactory.select(getProduct4DisplayDto(providerId)) .from(product) @@ -132,7 +136,7 @@ public Page findBySearchConditions(final String keyword, .orderBy(createOrderSpecifiers(product, pageable)); return toPage(pageable, contentQuery, countQuery); } - + @Override public Page findBySearchConditionsGroupByBrand(final String keyword, final Pageable pageable, @@ -148,7 +152,7 @@ public Page findBySearchConditionsGroupByBrand(final Stri .transform(groupBy(brand.brandId) .list(new QSimpleBrandProductDto(getSimpleBrandDto(), list(getProduct4DisplayDto(providerId)))) ); - + // TODO: 3/21/24 일단은 메모리에서 페이징하는 것으로 구현 final Map> productsGroupByBrand = fetch.stream() .skip(pageable.getOffset()) @@ -157,18 +161,18 @@ public Page findBySearchConditionsGroupByBrand(final Stri SimpleBrandProductDto::brand, brandProducts -> brandProducts.products() .subList(0, Math.min(brandProducts.products().size(), PRODUCT_SIZE_GROUP_BY_BRAND)), - (newVal,oldVal)->newVal + (newVal, oldVal) -> newVal //TODO 2024 04 25 15:31:13 : 검색 상품 이름으로 같은 브랜드의 상품들을 조회해 브랜드가 중복되어 조회되는 경우 )); - + final List content = productsGroupByBrand.keySet() .stream() .map(brand -> new SimpleBrandProductDto(brand, productsGroupByBrand.get(brand))) .toList(); - + return toPage(pageable, content, countQuery); } - + @Override public Map findAllPriceByIdsGroupById(final List productIds) { return queryFactory.selectFrom(product) @@ -178,7 +182,7 @@ public Map findAllPriceByIdsGroupById(final List productIds) { .as(product.price) ); } - + @Override public Map findAllNameByIdsGroupById(final List productIds) { return queryFactory.selectFrom(product) @@ -188,7 +192,7 @@ public Map findAllNameByIdsGroupById(final List productIds) .as(product.name) ); } - + @Override public OrderSpecifier[] getOrderSpecifiers(final Pageable pageable) { return Stream.concat( @@ -196,56 +200,84 @@ public OrderSpecifier[] getOrderSpecifiers(final Pageable pageable) { Stream.of(product.name.asc()) // 기본 정렬 조건 ).toArray(OrderSpecifier[]::new); } - + @Override - public DescriptionResponse findProductWithDetailsAndPhotos(Long productId) { - // 제품 기본 정보 조회 - Product product = Optional.ofNullable(queryFactory - .selectFrom(QProduct.product) - .where(QProduct.product.productId.eq(productId)) - .fetchOne()) - .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); - + public DescriptionResponse findProductWithDetailsAndPhotosWithoutMember(Product product) { + List descriptionPhotosUrls = queryFactory .select(QProductDescriptionPhoto.productDescriptionPhoto.photoUrl) .from(QProductDescriptionPhoto.productDescriptionPhoto) - .where(QProductDescriptionPhoto.productDescriptionPhoto.product.productId.eq(productId)) + .where(QProductDescriptionPhoto.productDescriptionPhoto.product.productId.eq(product.getProductId())) .fetch(); - - List optionsResponses = findOptions(productId); + + List optionsResponses = findOptions(product.getProductId()); List productThumbnailsUrls = queryFactory .select(QProductThumbnail.productThumbnail.thumbnailUrl) .from(QProductThumbnail.productThumbnail) - .where(QProductThumbnail.productThumbnail.product.productId.eq(productId)) + .where(QProductThumbnail.productThumbnail.product.productId.eq(product.getProductId())) .fetch(); - - return DescriptionResponse.of(product, descriptionPhotosUrls, optionsResponses, productThumbnailsUrls); + + return DescriptionResponse.of(product, descriptionPhotosUrls, optionsResponses, productThumbnailsUrls, false); } - - + @Override - public DetailResponse findProductDetail(Long productId) { - Product product = queryFactory + public DescriptionResponse findProductWithDetailsAndPhotosWithMember(Product product, Member member) { + List descriptionPhotosUrls = queryFactory + .select(QProductDescriptionPhoto.productDescriptionPhoto.photoUrl) + .from(QProductDescriptionPhoto.productDescriptionPhoto) + .where(QProductDescriptionPhoto.productDescriptionPhoto.product.productId.eq(product.getProductId())) + .fetch(); + + List optionsResponses = findOptions(product.getProductId()); + List productThumbnailsUrls = queryFactory + .select(QProductThumbnail.productThumbnail.thumbnailUrl) + .from(QProductThumbnail.productThumbnail) + .where(QProductThumbnail.productThumbnail.product.productId.eq(product.getProductId())) + .fetch(); + + Boolean isWished = isProductWishedByMember(product.getProductId(), member.getMemberId()); + + return DescriptionResponse.of(product, descriptionPhotosUrls, optionsResponses, productThumbnailsUrls, isWished); + } + + public Product findProductById(Long productId) { + return queryFactory .selectFrom(QProduct.product) .where(QProduct.product.productId.eq(productId)) .fetchOne(); - - if (product == null) { - throw new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND); - } - - List optionsResponses = findOptions(productId); - return DetailResponse.of(product, optionsResponses); } - - + + public DetailResponse findProductDetailWithoutMember(Product product) { + List optionsResponses = findOptions(product.getProductId()); + + return DetailResponse.of(product, optionsResponses, false); + } + @Override + public DetailResponse findProductDetailWithMember(Product product, Member member) { + List optionsResponses = findOptions(product.getProductId()); + + Boolean isWished = isProductWishedByMember(product.getProductId(), member.getMemberId()); + + return DetailResponse.of(product, optionsResponses, isWished); + } + + private boolean isProductWishedByMember(Long productId, Long memberId) { + Long wishId = queryFactory + .select(QWish.wish.wishId) + .from(QWish.wish) + .where(QWish.wish.product.productId.eq(productId) + .and(QWish.wish.member.memberId.eq(memberId))) + .fetchFirst(); + + return wishId != null; + } private QSimpleBrandDto getSimpleBrandDto() { return new QSimpleBrandDto( brand.brandId, brand.name, brand.iconPhoto); } - + private QProduct4DisplayDto getProduct4DisplayDto(final String providerId) { return new QProduct4DisplayDto( product.productId, @@ -256,9 +288,9 @@ private QProduct4DisplayDto getProduct4DisplayDto(final String providerId) { product.wishCount.longValue().as("wishCount"), isInWishList(providerId)); } - + private BooleanExpression isInWishList(final String providerId) { - if(providerId==null){ + if (providerId == null) { return Expressions.FALSE; } return JPAExpressions @@ -268,7 +300,7 @@ private BooleanExpression isInWishList(final String providerId) { wish.product.eq(product)) .exists(); } - + private QProductDto getProductDto() { return new QProductDto( product.productId, @@ -277,18 +309,18 @@ private QProductDto getProductDto() { product.price, product.brandName); } - + private BooleanExpression categoryIdEqualTo(final Long categoryId) { BooleanExpression isParentCategory = product.category .in(select(category) .from(category) .where(category.parent.categoryId.eq(categoryId))); - + BooleanExpression isChildCategory = product.category.categoryId.eq(categoryId); - + return isChildCategory.or(isParentCategory); } - + private JPAQuery countBrand(final Long brandId) { return queryFactory .select(product.countDistinct()) @@ -296,54 +328,51 @@ private JPAQuery countBrand(final Long brandId) { .join(product.brand, brand) .where(brand.brandId.eq(brandId)); } - + private JPAQuery countProduct(final Long categoryId) { return queryFactory .select(product.countDistinct()) .from(product) .where(categoryIdEqualTo(categoryId)); } - + private List findOptions(Long productId) { - - // 옵션과 옵션 상세 정보를 조회합니다. - return queryFactory - .select(Projections.constructor( - OptionResponse.class, - QOption.option.optionsId, - QOption.option.name, - GroupBy.list( - Projections.constructor( - ProductOptionDetailResponse.class, - QOptionDetail.optionDetail.optionDetailId, - QOptionDetail.optionDetail.name, - QOptionDetail.optionDetail.additionalPrice, - QOptionDetail.optionDetail.photo - ) - ) - )) - .from(QOption.option) - .innerJoin(QOptionDetail.optionDetail) - .on(QOptionDetail.optionDetail.option.optionsId.eq(QOption.option.optionsId)) - .where(QOption.option.product.productId.eq(productId)) - .transform( - GroupBy.groupBy(QOption.option.optionsId).list( - Projections.constructor( - OptionResponse.class, - QOption.option.optionsId, - QOption.option.name, - GroupBy.list( - Projections.constructor( - ProductOptionDetailResponse.class, - QOptionDetail.optionDetail.optionDetailId, - QOptionDetail.optionDetail.photo, - QOptionDetail.optionDetail.additionalPrice, - QOptionDetail.optionDetail.name - ) - ) - ) - ) - ); + return queryFactory + .select(Projections.constructor( + OptionResponse.class, + QOption.option.optionsId, + QOption.option.name, + GroupBy.list( + Projections.constructor( + ProductOptionDetailResponse.class, + QOptionDetail.optionDetail.optionDetailId, + QOptionDetail.optionDetail.name, + QOptionDetail.optionDetail.additionalPrice, + QOptionDetail.optionDetail.photo + ) + ) + )) + .from(QOption.option) + .leftJoin(QOptionDetail.optionDetail).on(QOptionDetail.optionDetail.option.optionsId.eq(QOption.option.optionsId)) + .where(QOption.option.product.productId.eq(productId)) + .transform( + GroupBy.groupBy(QOption.option.optionsId).list( + Projections.constructor( + OptionResponse.class, + QOption.option.optionsId, + QOption.option.name, + GroupBy.list( + Projections.constructor( + ProductOptionDetailResponse.class, + QOptionDetail.optionDetail.optionDetailId, + QOptionDetail.optionDetail.photo, + QOptionDetail.optionDetail.additionalPrice, + QOptionDetail.optionDetail.name + ) + ) + ) + ) + ); } - + } diff --git a/src/main/java/org/kakaoshare/backend/domain/product/service/ProductService.java b/src/main/java/org/kakaoshare/backend/domain/product/service/ProductService.java index e96429636..8b37a033a 100644 --- a/src/main/java/org/kakaoshare/backend/domain/product/service/ProductService.java +++ b/src/main/java/org/kakaoshare/backend/domain/product/service/ProductService.java @@ -1,10 +1,17 @@ package org.kakaoshare.backend.domain.product.service; +import io.micrometer.common.lang.Nullable; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.kakaoshare.backend.common.dto.PageResponse; +import org.kakaoshare.backend.common.error.GlobalErrorCode; +import org.kakaoshare.backend.common.error.exception.BusinessException; import org.kakaoshare.backend.common.util.sort.error.SortErrorCode; import org.kakaoshare.backend.common.util.sort.error.exception.NoMorePageException; +import org.kakaoshare.backend.domain.member.entity.Member; +import org.kakaoshare.backend.domain.member.exception.MemberErrorCode; +import org.kakaoshare.backend.domain.member.exception.MemberException; +import org.kakaoshare.backend.domain.member.repository.MemberRepository; import org.kakaoshare.backend.domain.product.dto.DescriptionResponse; import org.kakaoshare.backend.domain.product.dto.DetailResponse; import org.kakaoshare.backend.domain.product.dto.Product4DisplayDto; @@ -28,34 +35,43 @@ @Transactional(readOnly = true) public class ProductService { private final ProductRepository productRepository; + private final MemberRepository memberRepository; private final ApplicationEventPublisher eventPublisher; - - - public DescriptionResponse getProductDescription(Long productId) { - DescriptionResponse descriptionResponse = productRepository.findProductWithDetailsAndPhotos( - productId); - if (descriptionResponse == null) { - throw new EntityNotFoundException("Product not found with id: " + productId); + + + public DescriptionResponse getProductDescription(Long productId, @Nullable String providerId) { + Product product = findProductById(productId); + + if (providerId != null) { + Member member = findMemberById(providerId); + return productRepository.findProductWithDetailsAndPhotosWithMember(product, member); + } else { + return productRepository.findProductWithDetailsAndPhotosWithoutMember(product); } - return descriptionResponse; } - - public DetailResponse getProductDetail(Long productId) { - DetailResponse detailResponse = productRepository.findProductDetail(productId); - if (detailResponse == null) { - throw new EntityNotFoundException("Product not found with id: " + productId); // 이후 예외처리 추가 + + public DetailResponse getProductDetail(Long productId, @Nullable String providerId) { + Product product = findProductById(productId); + + if (product == null) { + throw new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND); } - return detailResponse; + + if (providerId != null) { + Member member = findMemberById(providerId); + return productRepository.findProductDetailWithMember(product, member); + } + return productRepository.findProductDetailWithoutMember(product); } - + public PageResponse getSimpleProductsPage(Long categoryId, Pageable pageable, final String providerId) { - Page productDtos = productRepository.findAllByCategoryId(categoryId, pageable,providerId); + Page productDtos = productRepository.findAllByCategoryId(categoryId, pageable, providerId); if (productDtos.isEmpty()) { throw new NoMorePageException(SortErrorCode.NO_MORE_PAGE); } return PageResponse.from(productDtos); } - + public PageResponse getSimpleProductsByBrandId(Long brandId, Pageable pageable) { Page productDtos = productRepository.findAllByBrandId(brandId, pageable); if (productDtos.isEmpty()) { @@ -63,37 +79,44 @@ public PageResponse getSimpleProductsByBrandId(Long brandId, Pageable pageabl } return PageResponse.from(productDtos); } - + /** * 위시 추가시 위시 서비스에서 비동기적으로 위시 리스트에 등록 + * * @see org.kakaoshare.backend.domain.wish.service.WishService */ @Transactional public WishResponse resisterProductInWishList(final String providerId, final Long productId, final WishType type) { Product product = findProductById(productId); - + product.increaseWishCount(); - - eventPublisher.publishEvent(WishReservationEvent.of(providerId,type,product)); + + eventPublisher.publishEvent(WishReservationEvent.of(providerId, type, product)); return WishResponse.from(product); } - + /** * 위시 취소시 위시 서비스에서 비동기적으로 위시 리스트에서 제거 + * * @see org.kakaoshare.backend.domain.wish.service.WishService */ @Transactional public WishResponse removeWishlist(final String providerId, final Long productId) { Product product = findProductById(productId); - + product.decreaseWishCount(); - - eventPublisher.publishEvent(WishCancelEvent.of(providerId,product)); + + eventPublisher.publishEvent(WishCancelEvent.of(providerId, product)); return WishResponse.from(product); } - + private Product findProductById(final Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new ProductException(ProductErrorCode.NOT_FOUND)); } + + private Member findMemberById(final String providerId) { + return memberRepository.findMemberByProviderId(providerId).orElseThrow(() -> new MemberException( + MemberErrorCode.NOT_FOUND)); + } } diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/controller/WishController.java b/src/main/java/org/kakaoshare/backend/domain/wish/controller/WishController.java index 8ee2debec..fe1cb4f3f 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/controller/WishController.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/controller/WishController.java @@ -1,13 +1,18 @@ package org.kakaoshare.backend.domain.wish.controller; import lombok.RequiredArgsConstructor; -import org.kakaoshare.backend.domain.wish.dto.WishDetail; +import org.kakaoshare.backend.common.dto.PageResponse; +import org.kakaoshare.backend.domain.wish.dto.FriendWishDetail; +import org.kakaoshare.backend.domain.wish.dto.FriendsWishRequest; import org.kakaoshare.backend.domain.wish.service.WishService; import org.kakaoshare.backend.jwt.util.LoggedInMember; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,18 +22,27 @@ @RestController @RequiredArgsConstructor public class WishController { + public static final int PAGE_DEFAULT_SIZE = 20; private final WishService wishService; @GetMapping("/me") - public ResponseEntity getWishList(@LoggedInMember String providerId) { - List wishList = wishService.getMembersWishList(providerId); + public ResponseEntity getWishList(@LoggedInMember String providerId, + @PageableDefault(size = PAGE_DEFAULT_SIZE) Pageable pageable) { + PageResponse wishList = wishService.getMembersWishList(pageable, providerId); return ResponseEntity.ok(wishList); } @PostMapping("/{wishId}/change-type") public ResponseEntity changeWishType(@LoggedInMember String providerId, @PathVariable(name = "wishId") Long wishId) { - wishService.changeWishType(providerId,wishId); + wishService.changeWishType(providerId, wishId); return ResponseEntity.ok().build(); } -} + + @GetMapping("/friends") + public ResponseEntity getFriendsWishList(@LoggedInMember String providerId, + @RequestBody FriendsWishRequest friendsWishRequest) { + List membersWishList = wishService.getFriendsWishList(providerId,friendsWishRequest); + return ResponseEntity.ok(membersWishList); + } +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendWishDetail.java b/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendWishDetail.java new file mode 100644 index 000000000..238265514 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendWishDetail.java @@ -0,0 +1,20 @@ +package org.kakaoshare.backend.domain.wish.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public final class FriendWishDetail{ + private final boolean isWished; + private final WishDetail wishDetail; + + @QueryProjection + public FriendWishDetail(WishDetail wishDetail, boolean isWished) { + this.wishDetail=wishDetail; + this.isWished = isWished; + } + + public boolean isWished() { + return isWished; + } +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendsWishRequest.java b/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendsWishRequest.java new file mode 100644 index 000000000..6c6f9dcb3 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/domain/wish/dto/FriendsWishRequest.java @@ -0,0 +1,4 @@ +package org.kakaoshare.backend.domain.wish.dto; + +public record FriendsWishRequest(String friendsProviderId, String kakaoAccessToken) { +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/dto/MyWishDetail.java b/src/main/java/org/kakaoshare/backend/domain/wish/dto/MyWishDetail.java new file mode 100644 index 000000000..113a45827 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/domain/wish/dto/MyWishDetail.java @@ -0,0 +1,19 @@ +package org.kakaoshare.backend.domain.wish.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public final class MyWishDetail { + private final boolean isPublic; + private final WishDetail wishDetail; + @QueryProjection + public MyWishDetail(final boolean isPublic, final WishDetail wishDetail) { + this.isPublic = isPublic; + this.wishDetail = wishDetail; + } + + public boolean isPublic() { + return isPublic; + } +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/dto/WishDetail.java b/src/main/java/org/kakaoshare/backend/domain/wish/dto/WishDetail.java index e41d06ee9..2608340f1 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/dto/WishDetail.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/dto/WishDetail.java @@ -1,3 +1,30 @@ package org.kakaoshare.backend.domain.wish.dto; -public record WishDetail(Long wishId,Long productId, String productName, Long productPrice, String productPhoto, boolean isPublic) { +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +@Getter +public class WishDetail { + private final Long wishId; + private final Long productId; + private final String productName; + private final Long productPrice; + private final String productPhoto; + private final String brandName; + private final Integer wishCount; + @QueryProjection + public WishDetail(final Long wishId, + final Long productId, + final String productName, + final Long productPrice, + final String productPhoto, + final String brandName, + final Integer wishCount) { + this.wishId = wishId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.productPhoto = productPhoto; + this.brandName = brandName; + this.wishCount = wishCount; + } } \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/repository/WishRepository.java b/src/main/java/org/kakaoshare/backend/domain/wish/repository/WishRepository.java index 710382940..c980815da 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/repository/WishRepository.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/repository/WishRepository.java @@ -6,11 +6,11 @@ import org.kakaoshare.backend.domain.wish.repository.query.WishRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; +import java.util.Optional; public interface WishRepository extends JpaRepository , WishRepositoryCustom { - List findByMember_ProviderId(final String providerId); - + Optional findByMember_ProviderIdAndWishId(final String member_providerId, final Long wishId); + void deleteByMemberAndProduct(final Member member, final Product product); -} +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustom.java b/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustom.java index 5e28ee381..af4620bbc 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustom.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustom.java @@ -1,12 +1,16 @@ package org.kakaoshare.backend.domain.wish.repository.query; import org.kakaoshare.backend.domain.member.entity.Member; -import org.kakaoshare.backend.domain.wish.dto.WishDetail; +import org.kakaoshare.backend.domain.wish.dto.FriendWishDetail; +import org.kakaoshare.backend.domain.wish.dto.MyWishDetail; import org.kakaoshare.backend.domain.wish.entity.Wish; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; public interface WishRepositoryCustom { - List findWishDetailsByProviderId(final String providerId); + Page findWishDetailsByProviderId(final Pageable pageable, final String providerId); + List findWishDetailsByFriendProviderId(final String providerId, final String friendsProviderId); boolean isContainInWishList(Wish wish, Member member, Long productId); -} +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustomImpl.java b/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustomImpl.java index f294106e1..b5e3f878e 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustomImpl.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/repository/query/WishRepositoryCustomImpl.java @@ -1,17 +1,25 @@ package org.kakaoshare.backend.domain.wish.repository.query; -import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.kakaoshare.backend.domain.member.entity.Member; -import org.kakaoshare.backend.domain.wish.dto.WishDetail; +import org.kakaoshare.backend.domain.wish.dto.FriendWishDetail; +import org.kakaoshare.backend.domain.wish.dto.MyWishDetail; +import org.kakaoshare.backend.domain.wish.dto.QFriendWishDetail; +import org.kakaoshare.backend.domain.wish.dto.QMyWishDetail; +import org.kakaoshare.backend.domain.wish.dto.QWishDetail; import org.kakaoshare.backend.domain.wish.entity.QWish; import org.kakaoshare.backend.domain.wish.entity.Wish; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; +import static org.kakaoshare.backend.common.util.RepositoryUtils.toPage; import static org.kakaoshare.backend.domain.member.entity.QMember.member; import static org.kakaoshare.backend.domain.product.entity.QProduct.product; import static org.kakaoshare.backend.domain.wish.entity.QWish.wish; @@ -19,24 +27,74 @@ @Repository @RequiredArgsConstructor public class WishRepositoryCustomImpl implements WishRepositoryCustom { + private static final int FRIEND_WISH_LIMIT = 10; private final JPAQueryFactory queryFactory; @Override - public List findWishDetailsByProviderId(final String providerId) { - return queryFactory + public Page findWishDetailsByProviderId(final Pageable pageable, + final String providerId) { + JPAQuery countQuery = queryFactory.select(wish.count()) + .from(wish) + .join(wish.member, member) + .on(wish.member.providerId.eq(providerId)) + .join(wish.product, product); + + JPAQuery contentQuery = queryFactory .select( - Projections.constructor( - WishDetail.class, - wish.wishId, - product.productId, - product.name, - product.price, - product.photo, - wish.isPublic)) + new QMyWishDetail(wish.isPublic, + new QWishDetail( + wish.wishId, + product.productId, + product.name, + product.price, + product.photo, + product.brandName, + product.wishCount))) .from(wish) .join(wish.member, member) .on(wish.member.providerId.eq(providerId)) .join(wish.product, product) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + return toPage(pageable, contentQuery, countQuery); + } + + private static BooleanExpression isInMyWishList(final String providerId, final QWish myWish, final QWish friendWish) { + return JPAExpressions.selectOne() + .from(myWish) + .where(myWish.member.providerId.eq(providerId) + .and(myWish.product.eq(friendWish.product))) + .exists(); + } + + @Override + public List findWishDetailsByFriendProviderId(final String providerId, + final String friendsProviderId) { + QWish friendWish = new QWish("friendWish"); + QWish myWish = new QWish("myWish"); + + return queryFactory + .select( + new QFriendWishDetail( + new QWishDetail( + friendWish.wishId, + product.productId, + product.name, + product.price, + product.photo, + product.brandName, + product.wishCount + ), + isInMyWishList(providerId, myWish, friendWish) + )) + .from(friendWish) + .join(friendWish.member, member) + .on(member.providerId.eq(friendsProviderId) + .and(friendWish.isPublic.isTrue())) + .join(friendWish.product, product) + //TODO 2024 05 27 20:04:17 : 친구 위시리스트 조회시 정렬 조건 구체화 후 논의 + .orderBy(friendWish.product.wishCount.desc()) + .limit(FRIEND_WISH_LIMIT) .fetch(); } @@ -50,7 +108,7 @@ public boolean isContainInWishList(Wish wish, Member member, Long productId) { .from(QWish.wish) .where(existsCondition) .fetchOne(); - + return count != null && count > 0; } -} +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/domain/wish/service/WishService.java b/src/main/java/org/kakaoshare/backend/domain/wish/service/WishService.java index 9f0c36a83..0cccd3d6a 100644 --- a/src/main/java/org/kakaoshare/backend/domain/wish/service/WishService.java +++ b/src/main/java/org/kakaoshare/backend/domain/wish/service/WishService.java @@ -2,6 +2,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.kakaoshare.backend.common.dto.PageResponse; +import org.kakaoshare.backend.common.util.sort.error.SortErrorCode; +import org.kakaoshare.backend.common.util.sort.error.exception.NoMorePageException; +import org.kakaoshare.backend.domain.friend.service.KakaoFriendService; +import org.kakaoshare.backend.domain.member.dto.oauth.profile.detail.KakaoFriendListDto; import org.kakaoshare.backend.domain.member.entity.Member; import org.kakaoshare.backend.domain.member.exception.MemberErrorCode; import org.kakaoshare.backend.domain.member.exception.MemberException; @@ -9,12 +14,16 @@ import org.kakaoshare.backend.domain.product.dto.WishCancelEvent; import org.kakaoshare.backend.domain.product.dto.WishEvent; import org.kakaoshare.backend.domain.product.entity.Product; -import org.kakaoshare.backend.domain.wish.dto.WishDetail; +import org.kakaoshare.backend.domain.wish.dto.FriendWishDetail; +import org.kakaoshare.backend.domain.wish.dto.FriendsWishRequest; +import org.kakaoshare.backend.domain.wish.dto.MyWishDetail; import org.kakaoshare.backend.domain.wish.dto.WishReservationEvent; import org.kakaoshare.backend.domain.wish.entity.Wish; import org.kakaoshare.backend.domain.wish.error.WishErrorCode; import org.kakaoshare.backend.domain.wish.error.exception.WishException; import org.kakaoshare.backend.domain.wish.repository.WishRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; @@ -29,11 +38,12 @@ @Slf4j @Service -@Transactional +@Transactional(readOnly = true) @RequiredArgsConstructor public class WishService { private final WishRepository wishRepository; private final MemberRepository memberRepository; + private final KakaoFriendService kakaoFriendService; @Async @Transactional(propagation = Propagation.REQUIRES_NEW)//TODO 2024 04 11 18:57:19 : 성능 저하 가능성 있음 @@ -73,7 +83,7 @@ public void handleWishReservation(WishReservationEvent event) { public void handleWishCancel(WishCancelEvent event) { final Member member = getMember(event.getProviderId()); final Product product = event.getProduct(); - + try { wishRepository.deleteByMemberAndProduct(member, product); } catch (RuntimeException e) { @@ -93,14 +103,36 @@ public void recoverCancel(RuntimeException e, WishCancelEvent event) { event.getProduct().increaseWishCount(); } - @Transactional(readOnly = true) + public Member getMember(final String providerId) { return memberRepository.findMemberByProviderId(providerId) .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); } - public List getMembersWishList(final String providerId) { - return wishRepository.findWishDetailsByProviderId(providerId); + public PageResponse getMembersWishList(final Pageable pageable, + final String providerId) { + Page myWishDetails = wishRepository.findWishDetailsByProviderId(pageable, providerId); + if (myWishDetails.isEmpty()) { + throw new NoMorePageException(SortErrorCode.NO_MORE_PAGE); + } + return PageResponse.from(myWishDetails); + } + + + public List getFriendsWishList(final String providerId, final FriendsWishRequest friendsWishRequest) { + checkIsFriend(friendsWishRequest); + return wishRepository.findWishDetailsByFriendProviderId(providerId, friendsWishRequest.friendsProviderId()); + } + + private void checkIsFriend(final FriendsWishRequest friendsWishRequest) { + List friends = kakaoFriendService.getFriendsList(friendsWishRequest.kakaoAccessToken()); + boolean isFriend = friends.stream() + .anyMatch(kakaoFriendListDto -> kakaoFriendListDto + .getId() + .equals(friendsWishRequest.friendsProviderId())); + if (!isFriend) { + throw new MemberException(MemberErrorCode.NO_SUCH_RELATIONSHIP); + } } private Wish createWish(final WishEvent event, final Member member) { @@ -110,13 +142,10 @@ private Wish createWish(final WishEvent event, final Member member) { .build(); } + @Transactional public void changeWishType(final String providerId, final Long wishId) { - boolean exists = getMembersWishList(providerId).stream() - .anyMatch(wishDetail -> wishDetail.wishId().equals(wishId)); - if (!exists) { - throw new WishException(WishErrorCode.NOT_FOUND); - } - Wish wish = wishRepository.findById(wishId).orElseThrow(() -> new WishException(WishErrorCode.NOT_FOUND)); + Wish wish = wishRepository.findByMember_ProviderIdAndWishId(providerId, wishId) + .orElseThrow(() -> new WishException(WishErrorCode.NOT_FOUND)); wish.changeScopeOfDisclosure(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/kakaoshare/backend/jwt/exception/JwtErrorCode.java b/src/main/java/org/kakaoshare/backend/jwt/exception/JwtErrorCode.java index 205cf1de5..c27ea0ce7 100644 --- a/src/main/java/org/kakaoshare/backend/jwt/exception/JwtErrorCode.java +++ b/src/main/java/org/kakaoshare/backend/jwt/exception/JwtErrorCode.java @@ -6,10 +6,10 @@ @Getter public enum JwtErrorCode implements ErrorCode { - NOT_FOUND(CODE_PREFIX + "001", HttpStatus.NOT_FOUND,"토큰을 찾을 수 없습니다."), - INVALID(CODE_PREFIX + "002", HttpStatus.BAD_REQUEST,"유효하지 않은 토큰입니다."), - EXPIRED(CODE_PREFIX + "003", HttpStatus.BAD_REQUEST,"만료된 토큰입니다."), - UNSUPPORTED(CODE_PREFIX + "004", HttpStatus.BAD_REQUEST,"JWT를 지원하지 않습니다."); + NOT_FOUND(CODE_PREFIX + "001", HttpStatus.UNAUTHORIZED,"토큰을 찾을 수 없습니다."), + INVALID(CODE_PREFIX + "002", HttpStatus.UNAUTHORIZED,"유효하지 않은 토큰입니다."), + EXPIRED(CODE_PREFIX + "003", HttpStatus.UNAUTHORIZED,"만료된 토큰입니다."), + UNSUPPORTED(CODE_PREFIX + "004", HttpStatus.UNAUTHORIZED,"JWT를 지원하지 않습니다."); private final String code; private final HttpStatus httpStatus; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8d3897bf6..045104380 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,16 +13,23 @@ spring: exception-request-attribute-name : jwtException jpa: hibernate: - ddl-auto: create + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect +# format_sql: true +# use_sql_comments: true +# show-sql: true + datasource: - url: jdbc:h2:mem:test - username: sa - password: - driver-class-name: org.h2.Driver + url: jdbc:mysql://localhost:3306/funding?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: qal,1 + driver-class-name: com.mysql.cj.jdbc.Driver flyway: - enabled: false - locations: "classpath:db/migration,filesystem:/opt/migration" - baseline-on-migrate: false + enabled: true + locations: "classpath:db/migration,filesystem:/home/ec2-user/migration" + baseline-on-migrate: true security: oauth2: client: @@ -52,6 +59,8 @@ spring: other: kakao: logout-url: https://kapi.kakao.com/v1/user/logout +# config: +# import: classpath:application-monitor.yml security: token: @@ -71,7 +80,14 @@ pay: approve: https://open-api.kakaopay.com/online/v1/payment/approve cancel: https://open-api.kakaopay.com/online/v1/payment/cancel redirect-url: - approval: http://localhost:5173/payments/success - cancel: http://localhost:5173/payments/cancel - fail: http://localhost:5173/payments/fail + approval: http://localhost:8080/payments/success + cancel: http://localhost:8080/payments/cancel + fail: http://localhost:8080/payments/fail + + +logging: + level: + org.springframework.security: trace +# org.hibernate.type.descriptor.sql: trace + diff --git a/src/main/resources/application-monitor.yml b/src/main/resources/application-monitor.yml new file mode 100644 index 000000000..6ba599456 --- /dev/null +++ b/src/main/resources/application-monitor.yml @@ -0,0 +1,15 @@ +management: + prometheus: + metrics: + export: + enabled: true + endpoint: + metrics: + enabled: true + endpoints: + web: + exposure: + include: "*" + metrics: + tags: + application: ${spring.application.name} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 41a5f1db9..9052f96fa 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,35 +1,85 @@ spring: + h2: + console: + enabled: true + settings: + web-allow-others: true + data: + redis: + host: localhost + port: 6379 + jwt: + secret: 0f4aeeb70d87e0cd8e6b224c31f56911134c713ea35d2cdf8209fe31047b9c1dc2f11086ed708fc2a605f8b2f019121c8f471e68d0061bce30a2139c0b60a383 + exception-request-attribute-name : jwtException + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + defer-datasource-initialization: true + sql: + init: + mode: always + data-locations: classpath:testdata/initial_*.sql datasource: url: jdbc:h2:mem:test - username: sa + username: root password: driver-class-name: org.h2.Driver flyway: enabled: false baseline-on-migrate: false + security: + oauth2: + client: + registration: + kakao: + client-id: 92e3a72f071fe2c4cc18146bb33652d1 + client-secret: QH0n8i9qoDE4TkeVl9p9aFPRQVcIgXHC + client-authentication-method: POST + client-name: kakao + authorization-grant-type: refresh_token + scope: + - profile_nickname + - profile_image + - name + - gender + - age_range + - birthday + - birthyear + - phone_number + - friends + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + other: + kakao: + logout-url: https://kapi.kakao.com/v1/user/logout - sql: - init: - mode: always - data-locations: classpath:testdata/initial_*.sql - - jpa: - hibernate: - ddl-auto: create - properties: - hibernate: - show_sql: true - format_sql: true - use_sql_comments: true - defer-datasource-initialization: true - - - - - -logging: - level: - org.org.hibernate.SQL: debug - org.hibernate.type: trace +security: + token: + access: + expire-time: 3600000 + refresh: + expire-time: 86400000 +friend: + request-url: https://kapi.kakao.com/v1/api/talk/friends +pay: + client: + id: TC0ONETIME + secret: EA249FF02E1D8AB792A2 + secret-key: DEVE0FAC7BBB6C06C73602C9676CEC0BA0D655EA + request-url: + ready: https://open-api.kakaopay.com/online/v1/payment/ready + approve: https://open-api.kakaopay.com/online/v1/payment/approve + cancel: https://open-api.kakaopay.com/online/v1/payment/cancel + redirect-url: + approval: http://localhost:8080/payments/success + cancel: http://localhost:8080/payments/cancel + fail: http://localhost:8080/payments/fail diff --git a/src/main/resources/db/migration/V7__modify_funding_idx.sql b/src/main/resources/db/migration/V7__modify_funding_idx.sql index e398b0494..18a8cea26 100644 --- a/src/main/resources/db/migration/V7__modify_funding_idx.sql +++ b/src/main/resources/db/migration/V7__modify_funding_idx.sql @@ -1,14 +1,14 @@ ALTER TABLE funding - DROP FOREIGN KEY `FK308j7d5ln7xaq590xo8bxwul0`; +DROP FOREIGN KEY `FK308j7d5ln7xaq590xo8bxwul0`; ALTER TABLE funding - DROP FOREIGN KEY `FK5cxch4qfn9ynsvod79uyulcvj`; +DROP FOREIGN KEY `FK5cxch4qfn9ynsvod79uyulcvj`; ALTER TABLE funding - DROP INDEX `idx_funding_member_id`; +DROP INDEX `idx_funding_member_id`; ALTER TABLE funding - DROP INDEX `idx_funding_product_id`; +DROP INDEX `idx_funding_product_id`; ALTER TABLE funding ADD CONSTRAINT `FK308j7d5ln7xaq590xo8bxwul0` diff --git a/src/test/java/org/kakaoshare/backend/BackEndApplicationTests.java b/src/test/java/org/kakaoshare/backend/BackEndApplicationTests.java index 1443ffee6..bb5a4417b 100644 --- a/src/test/java/org/kakaoshare/backend/BackEndApplicationTests.java +++ b/src/test/java/org/kakaoshare/backend/BackEndApplicationTests.java @@ -2,12 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BackEndApplicationTests { - - @Test - void contextLoads() { - } - + + @Test + void contextLoads() { + } + } diff --git a/src/test/java/org/kakaoshare/backend/domain/product/service/ProductServiceTest.java b/src/test/java/org/kakaoshare/backend/domain/product/service/ProductServiceTest.java index 76c875899..e7a243d54 100644 --- a/src/test/java/org/kakaoshare/backend/domain/product/service/ProductServiceTest.java +++ b/src/test/java/org/kakaoshare/backend/domain/product/service/ProductServiceTest.java @@ -1,13 +1,19 @@ package org.kakaoshare.backend.domain.product.service; import jakarta.persistence.EntityNotFoundException; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.kakaoshare.backend.common.error.exception.BusinessException; +import org.kakaoshare.backend.domain.member.entity.Member; +import org.kakaoshare.backend.domain.member.repository.MemberRepository; import org.kakaoshare.backend.domain.product.dto.DescriptionResponse; import org.kakaoshare.backend.domain.product.dto.DetailResponse; import org.kakaoshare.backend.domain.product.entity.Product; +import org.kakaoshare.backend.domain.product.exception.ProductException; import org.kakaoshare.backend.domain.product.repository.ProductRepository; +import org.kakaoshare.backend.fixture.MemberFixture; import org.kakaoshare.backend.fixture.ProductFixture; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -21,7 +27,8 @@ public class ProductServiceTest { @Mock private ProductRepository productRepository; - + @Mock + private MemberRepository memberRepository; @InjectMocks private ProductService productService; @@ -30,18 +37,23 @@ public class ProductServiceTest { @Test @DisplayName("상품 상세정보 조회 성공") void getProductDetail_Success() { - final Product product = ProductFixture.TEST_PRODUCT.생성(); - final Long productId = product.getProductId(); + // Arrange + Member member = MemberFixture.KAKAO.생성(); + Product product = ProductFixture.TEST_PRODUCT.생성(); + Long productId = product.getProductId(); DetailResponse expectedDetailResponse = DetailResponse.builder() .deliverDescription("배송 설명") .build(); - doReturn(expectedDetailResponse) - .when(productRepository) - .findProductDetail(productId); + when(memberRepository.findMemberByProviderId(member.getProviderId())) + .thenReturn(Optional.of(member)); + when(productRepository.findById(productId)) + .thenReturn(Optional.of(product)); + when(productRepository.findProductDetailWithMember(product, member)) + .thenReturn(expectedDetailResponse); - final DetailResponse actual = productService.getProductDetail(productId); + DetailResponse actual = productService.getProductDetail(productId, member.getProviderId()); assertEquals(expectedDetailResponse.getDeliverDescription(), actual.getDeliverDescription()); } @@ -49,30 +61,34 @@ void getProductDetail_Success() { @Test @DisplayName("상품 상세설명 조회 성공") void getProductDescription_Success() { - final Long productId = 1L; + Member member = MemberFixture.KAKAO.생성(); + Product product = ProductFixture.TEST_PRODUCT.생성(); + Long productId = product.getProductId(); + DescriptionResponse expectedDescriptionResponse = DescriptionResponse.builder() .description("설명") .build(); + when(memberRepository.findMemberByProviderId(member.getProviderId())) + .thenReturn(Optional.of(member)); + when(productRepository.findById(productId)) + .thenReturn(Optional.of(product)); doReturn(expectedDescriptionResponse) .when(productRepository) - .findProductWithDetailsAndPhotos(productId); + .findProductWithDetailsAndPhotosWithMember(product,member); - DescriptionResponse actualDescriptionResponse = productService.getProductDescription(productId); + DescriptionResponse actualDescriptionResponse = productService.getProductDescription(productId,member.getProviderId()); assertEquals(expectedDescriptionResponse, actualDescriptionResponse); - verify(productRepository).findProductWithDetailsAndPhotos(productId); + verify(productRepository).findProductWithDetailsAndPhotosWithMember(product,member); } @Test @DisplayName("존재하지 않는 상품 ID로 조회 시 예외 발생") void getProductDetail_WhenProductNotFound_ThenThrowException() { - final Long nonExistingProductId = 999L; - - when(productRepository.findProductWithDetailsAndPhotos(nonExistingProductId)).thenReturn(null); + Long nonExistingProductId = 999L; - assertThatThrownBy(() -> productService.getProductDescription(nonExistingProductId)) - .isInstanceOf(EntityNotFoundException.class) - .hasMessageContaining("Product not found with id: " + nonExistingProductId); + assertThatThrownBy(() -> productService.getProductDescription(nonExistingProductId, null)) + .isInstanceOf(ProductException.class); } } diff --git a/src/test/java/org/kakaoshare/backend/domain/wish/service/WishServiceTest.java b/src/test/java/org/kakaoshare/backend/domain/wish/service/WishServiceTest.java deleted file mode 100644 index a24f79e7a..000000000 --- a/src/test/java/org/kakaoshare/backend/domain/wish/service/WishServiceTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package org.kakaoshare.backend.domain.wish.service; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.kakaoshare.backend.domain.member.entity.Member; -import org.kakaoshare.backend.domain.member.repository.MemberRepository; -import org.kakaoshare.backend.domain.product.dto.WishCancelEvent; -import org.kakaoshare.backend.domain.product.dto.WishType; -import org.kakaoshare.backend.domain.product.entity.Product; -import org.kakaoshare.backend.domain.product.repository.ProductRepository; -import org.kakaoshare.backend.domain.product.service.ProductService; -import org.kakaoshare.backend.domain.wish.dto.WishDetail; -import org.kakaoshare.backend.domain.wish.dto.WishReservationEvent; -import org.kakaoshare.backend.domain.wish.entity.Wish; -import org.kakaoshare.backend.domain.wish.error.WishErrorCode; -import org.kakaoshare.backend.domain.wish.error.exception.WishException; -import org.kakaoshare.backend.domain.wish.repository.WishRepository; -import org.kakaoshare.backend.fixture.MemberFixture; -import org.kakaoshare.backend.fixture.ProductFixture; -import org.kakaoshare.backend.fixture.WishFixture; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.event.ApplicationEvents; -import org.springframework.test.context.event.RecordApplicationEvents; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.when; - -@SpringBootTest -@RecordApplicationEvents -class WishServiceTest { - @Autowired - ApplicationEvents events; - - @MockBean - private ProductRepository productRepository; - - Member member; - Product product; - @MockBean - private WishRepository wishRepository; - - @Autowired - private ProductService productService; - @MockBean - private MemberRepository memberRepository; - @Autowired - private WishService wishService; - - @BeforeEach - void setUp() { - member = MemberFixture.KAKAO.생성(); - product = ProductFixture.TEST_PRODUCT.생성(1L); - } - - @Test - @Transactional - @DisplayName("상품 위시 등록 이벤트는 비동기적으로 처리된다.") - void testWishReservateInAsync() { - // given - when(productRepository.findById(product.getProductId())) - .thenReturn(Optional.of(product)); - // when - productService.resisterProductInWishList(member.getProviderId(), product.getProductId(), WishType.ME); - WishReservationEvent event = events.stream(WishReservationEvent.class) - .findFirst() - .orElseThrow(); - - // then - assertThat(event).isNotNull(); - assertThat(event.getProduct()).isNotNull(); - assertThat(event.getProviderId()).isEqualTo(member.getProviderId()); - assertThat(event.getType()).isEqualTo(WishType.ME); - } - - @Test - @Transactional - @DisplayName("상품 위시 취소 이벤트는 비동기적으로 처리된다.") - void testWishCancelInAsync() { - // given - when(productRepository.findById(product.getProductId())) - .thenReturn(Optional.of(product)); - // when - productService.removeWishlist(member.getProviderId(), product.getProductId()); - WishCancelEvent event = events.stream(WishCancelEvent.class) - .findFirst() - .orElseThrow(); - - // then - assertThat(event).isNotNull(); - assertThat(event.getProduct()).isNotNull(); - assertThat(event.getProviderId()).isEqualTo(member.getProviderId()); - } - - @Test - @Transactional - @DisplayName("위시 등록 실패시 상품의 wish count는 복구된다") - void testReservationFailedRecover() { - // given - given(productRepository.findById(product.getProductId())) - .willReturn(Optional.of(product)); - - given(memberRepository.findMemberByProviderId(member.getProviderId())) - .willReturn(Optional.of(member)); - when(wishRepository.save(any(Wish.class))) - .thenThrow(new WishException(WishErrorCode.SAVING_FAILED)); - - // when - productService.resisterProductInWishList(member.getProviderId(), product.getProductId(), WishType.ME); - WishReservationEvent event = events.stream(WishReservationEvent.class) - .findFirst() - .orElseThrow(); - // then - wishService.handleWishReservation(event); - assertThat(product.getWishCount()).isEqualTo(2); - wishService.recoverReservation(new WishException(WishErrorCode.SAVING_FAILED), event); - assertThat(product.getWishCount()).isEqualTo(1); - } - - @Test - @Transactional - @DisplayName("위시 삭제 실패시 상품의 wish count는 복구된다") - void testWishCancelFailed() { - // given - given(productRepository.findById(product.getProductId())) - .willReturn(Optional.of(product)); - - given(memberRepository.findMemberByProviderId(member.getProviderId())) - .willReturn(Optional.of(member)); - doThrow(new WishException(WishErrorCode.REMOVING_FAILED)) - .when(wishRepository) - .delete(any(Wish.class)); - - // when - productService.removeWishlist(member.getProviderId(), product.getProductId()); - WishCancelEvent event = events.stream(WishCancelEvent.class) - .findFirst() - .orElseThrow(); - - // then - wishService.handleWishCancel(event); - assertThat(product.getWishCount()).isEqualTo(0); - wishService.recoverCancel(new WishException(WishErrorCode.REMOVING_FAILED), event); - assertThat(product.getWishCount()).isEqualTo(1); - } - - @Test - @DisplayName("위시 공개 범위 수정 요청은 공개 범위를 반전시킨다.") - void testChangeScopeOfDisclosure() { - // given - Wish wish = WishFixture.TEST_WISH3.생성();//isPublic=true - Boolean isPublic = wish.getIsPublic(); - WishDetail wishDetail = new WishDetail( - wish.getWishId(), - product.getProductId(), - product.getName(), - product.getPrice(), - product.getPhoto(), - wish.getIsPublic()); - when(wishRepository.findById(any())) - .thenReturn(Optional.of(wish)); - when(wishRepository.findWishDetailsByProviderId(any())) - .thenReturn(List.of(wishDetail)); - - // when - wishService.changeWishType(member.getProviderId(), wish.getWishId()); - - // then - assertThat(wish.getIsPublic()).isEqualTo(!isPublic); - } -} \ No newline at end of file diff --git a/src/test/java/org/kakaoshare/backend/fixture/ProductFixture.java b/src/test/java/org/kakaoshare/backend/fixture/ProductFixture.java index 79b27983d..4c9f24012 100644 --- a/src/test/java/org/kakaoshare/backend/fixture/ProductFixture.java +++ b/src/test/java/org/kakaoshare/backend/fixture/ProductFixture.java @@ -5,32 +5,35 @@ import org.kakaoshare.backend.domain.product.entity.Product; public enum ProductFixture { - TEST_PRODUCT("Test Product", 999L, "Test Type"), - CAKE("케이크", 10_000L, "Dessert"), - COFFEE("커피", 3_000L, "Beverage"); - + TEST_PRODUCT("Test Product", 1L, 999L, "Test Type"), + CAKE("케이크", 2L, 10_000L, "Dessert"), + COFFEE("커피", 3L, 3_000L, "Beverage"); + private final String name; + private final Long productId; private final Long price; private final String type; - ProductFixture(String name, Long price, String type) { + ProductFixture(String name, Long productId, Long price, String type) { this.name = name; + this.productId = productId; this.price = price; this.type = type; } - + + public Product 생성() { return 생성(null); } - + public Product 생성(final Long productId) { return 생성(productId, null); } - + public Product 생성(final Long productId, final Brand brand) { return 생성(productId, brand, this.price); } - + public Product 생성(final Long productId, final Brand brand, final Long price) { Category category = Category.builder() .name("category") @@ -46,15 +49,15 @@ public enum ProductFixture { .wishCount(1) .build(); } - + public Product 브랜드_설정_생성(final Brand brand) { return 생성(null, brand); } - + public Product 가격_설정_생성(final Long price) { return 생성(null, null, price); } - + public Product 브랜드_가격_설정_생성(final Brand brand, final Long price) { return 생성(null, brand, price);