diff --git a/build.gradle b/build.gradle index c89ab8d..9664f79 100644 --- a/build.gradle +++ b/build.gradle @@ -30,9 +30,13 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' // 실제 서버용 (MySQL) runtimeOnly 'com.h2database:h2' // 테스트, 로컬용 (H2) - // 3. 롬복 (Lombok) - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + // 3. 롬복 (Lombok) Java 21 + query dsl 조합시 롬복 오류로 인해 수정하였습니다. +// compileOnly 'org.projectlombok:lombok' +// annotationProcessor 'org.projectlombok:lombok' + + compileOnly 'org.projectlombok:lombok:1.18.32' + annotationProcessor 'org.projectlombok:lombok:1.18.32' + // 4. 인증/인가 (Security + JWT) implementation 'org.springframework.boot:spring-boot-starter-security' @@ -40,11 +44,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' - // 5. QueryDSL (스프링 부트 3.x 이상은 Jakarta 버전 필수) - // implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - // annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" - // annotationProcessor "jakarta.annotation:jakarta.annotation-api" - // annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // 5. QueryDSL (스프링 부트 3.x 이상은 Jakarta 버전 필수) - OpenFeign + implementation "io.github.openfeign.querydsl:querydsl-jpa:7.1" + implementation "io.github.openfeign.querydsl:querydsl-core:7.1" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.1:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" // 6. Swagger (SpringDoc OpenAPI - 스프링 부트 3.4와 호완을 위해 2.7 이상을 사용) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' @@ -53,25 +58,30 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' // 시큐리티 테스트용 testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok:1.18.32' // 텍스트 롬복 추가 + testAnnotationProcessor 'org.projectlombok:lombok:1.18.32' + } tasks.withType(Test).configureEach { useJUnitPlatform() } -// QueryDSL Q클래스 생성 위치를 깔끔하게 정리 (선택 사항이지만 추천) -/* -def querydslDir = "$buildDir/generated/querydsl" - -tasks.withType(JavaCompile).configureEach { - options.getGeneratedSourceOutputDirectory().set(file(querydslDir)) -} +// QueryDSL 관련 설정 +// generated/querydsl 폴더 생성 & 삽입 +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile +// 소스 세트에 생성 경로 추가 (구체적인 경로 지정) sourceSets { main.java.srcDirs += [ querydslDir ] } -clean { - delete file(querydslDir) +// 컴파일 시 생성 경로 지정 +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(querydslDir) } - */ \ No newline at end of file + +// clean 태스크에 생성 폴더 삭제 로직 추가 +clean.doLast { + file(querydslDir).deleteDir() +} \ No newline at end of file diff --git a/src/main/java/com/umc/apiwiki/domain/api/controller/ApiController.java b/src/main/java/com/umc/apiwiki/domain/api/controller/ApiController.java index 9cb856a..4dd939e 100644 --- a/src/main/java/com/umc/apiwiki/domain/api/controller/ApiController.java +++ b/src/main/java/com/umc/apiwiki/domain/api/controller/ApiController.java @@ -1,23 +1,68 @@ package com.umc.apiwiki.domain.api.controller; import com.umc.apiwiki.domain.api.dto.ApiDTO; -import com.umc.apiwiki.domain.api.service.query.ApiQueryService; +import com.umc.apiwiki.domain.api.service.query.ApiSearchQueryService; +import com.umc.apiwiki.domain.api.service.query.ApiDetailQueryService; +import com.umc.apiwiki.domain.api.enums.*; import com.umc.apiwiki.global.apiPayload.ApiResponse; import com.umc.apiwiki.global.apiPayload.code.GeneralSuccessCode; +import com.umc.apiwiki.global.apiPayload.dto.PageResponseDTO; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.Page; + +import java.math.BigDecimal; @RestController @RequiredArgsConstructor -public class ApiController implements ApiControllerDocs { +@RequestMapping("/api/v1") +public class ApiController implements ApiControllerDocs{ - private final ApiQueryService apiQueryService; + private final ApiDetailQueryService apiDetailQueryService; + private final ApiSearchQueryService apiSearchQueryService; - @GetMapping("/{apiId}") + @GetMapping("/apis/{apiId}") public ApiResponse getApiDetail(@PathVariable Long apiId) { return ApiResponse.onSuccess( GeneralSuccessCode.OK, - apiQueryService.getApiDetail(apiId) + apiDetailQueryService.getApiDetail(apiId) ); } -} + + @GetMapping("/apis") + public ApiResponse> searchApis( + // page는 0-based 로 명시(Pageable 기준과 일치) + // 음수 방지 + @RequestParam(defaultValue = "0") @PositiveOrZero int page, + + // size는 null 허용하지만 값이 있으면 양수만 허용 + @RequestParam(required = false, defaultValue = "16") @Positive Integer size, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) String q, // 검색어 + @RequestParam(required = false, defaultValue = "LATEST") ApiSortType sort, // 정렬 옵션 + @RequestParam(defaultValue = "ASC") SortDirection direction, // 정렬 방향 + @RequestParam(required = false, name = "providers") ProviderCompany providerCompany, + @RequestParam(required = false, name = "authTypes") AuthType authType, + @RequestParam(required = false, name = "pricingTypes") PricingType pricingType, + @RequestParam(required = false) @Positive @DecimalMax("5.0") BigDecimal minRating + ) { + + Page resultPage = apiSearchQueryService.searchApis( + page, + size, + categoryId, + q, + sort, + direction, + providerCompany, + authType, + pricingType, + minRating + ); + + return ApiResponse.onPageSuccess(GeneralSuccessCode.OK, resultPage); + } +} \ No newline at end of file diff --git a/src/main/java/com/umc/apiwiki/domain/api/controller/ApiControllerDocs.java b/src/main/java/com/umc/apiwiki/domain/api/controller/ApiControllerDocs.java index 8e07a4c..845521a 100644 --- a/src/main/java/com/umc/apiwiki/domain/api/controller/ApiControllerDocs.java +++ b/src/main/java/com/umc/apiwiki/domain/api/controller/ApiControllerDocs.java @@ -1,14 +1,57 @@ package com.umc.apiwiki.domain.api.controller; import com.umc.apiwiki.domain.api.dto.ApiDTO; +import com.umc.apiwiki.domain.api.enums.*; import com.umc.apiwiki.global.apiPayload.ApiResponse; +import com.umc.apiwiki.global.apiPayload.dto.PageResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import java.math.BigDecimal; + +@Tag(name = "API 탐색", description = "Explore 페이지 API 목록 조회 + 필터 + 상세 조회") public interface ApiControllerDocs { + // API 목록 조회, 필터링 + @Operation( + summary = "API 목록 조회 (Explore + 필터) By 악어", + description = """ + Explore 페이지용 API 목록 조회입니다. + + ▪ page는 0-based 입니다. + ▪ 기본 size = 16 + ▪ 모든 필터는 조합 가능합니다. + + [정렬 옵션] + - latest : 최신 등록순 (기본값) + - popular : 조회수 순 + - mostReviewed : 리뷰 많은 순 + """ + ) + @GetMapping("/apis") + ApiResponse> searchApis( + + @PositiveOrZero int page, + @Positive Integer size, + Long categoryId, + String q, + ApiSortType sort, + SortDirection direction, + ProviderCompany providerCompany, + AuthType authType, + PricingType pricingType, + @Positive + @DecimalMax("5.0") + BigDecimal minRating + ); + + // API 상세 조회 @Operation( summary = "API 상세 조회 By 제인", description = "API 개요 탭에서 한줄 설명, 카테고리 태그, 긴 설명 등을 반환합니다." @@ -21,4 +64,4 @@ public interface ApiControllerDocs { ApiResponse getApiDetail( @PathVariable Long apiId ); -} +} \ No newline at end of file diff --git a/src/main/java/com/umc/apiwiki/domain/api/dto/ApiDTO.java b/src/main/java/com/umc/apiwiki/domain/api/dto/ApiDTO.java index 3d070ad..b604d08 100644 --- a/src/main/java/com/umc/apiwiki/domain/api/dto/ApiDTO.java +++ b/src/main/java/com/umc/apiwiki/domain/api/dto/ApiDTO.java @@ -1,12 +1,30 @@ package com.umc.apiwiki.domain.api.dto; +import com.umc.apiwiki.domain.api.enums.AuthType; +import com.umc.apiwiki.domain.api.enums.PricingType; +import com.umc.apiwiki.domain.api.enums.ProviderCompany; + import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; public class ApiDTO { - public static record ApiDetail( + // Explore / 목록 조회 DTO by 악어 + public record ApiPreview( + Long apiId, + String name, + String summary, + BigDecimal avgRating, + Long reviewCount, + Long viewCounts, + PricingType pricingType, + AuthType authType, + ProviderCompany providerCompany + ) {} + + // 상세 조회 DTO by 재인 + public record ApiDetail( Long apiId, String name, String summary, @@ -18,12 +36,10 @@ public static record ApiDetail( String logo, LocalDateTime createdAt, LocalDateTime updatedAt - ) { - } + ) {} - public static record CategoryItem( + public record CategoryItem( Long categoryId, String name - ) { - } + ) {} } diff --git a/src/main/java/com/umc/apiwiki/domain/api/enums/ApiSortType.java b/src/main/java/com/umc/apiwiki/domain/api/enums/ApiSortType.java new file mode 100644 index 0000000..271bab2 --- /dev/null +++ b/src/main/java/com/umc/apiwiki/domain/api/enums/ApiSortType.java @@ -0,0 +1,7 @@ +package com.umc.apiwiki.domain.api.enums; + +public enum ApiSortType { + LATEST, + POPULAR, + MOST_REVIEWED +} diff --git a/src/main/java/com/umc/apiwiki/domain/api/enums/SortDirection.java b/src/main/java/com/umc/apiwiki/domain/api/enums/SortDirection.java new file mode 100644 index 0000000..a452de7 --- /dev/null +++ b/src/main/java/com/umc/apiwiki/domain/api/enums/SortDirection.java @@ -0,0 +1,6 @@ +package com.umc.apiwiki.domain.api.enums; + +public enum SortDirection { + ASC, + DESC +} diff --git a/src/main/java/com/umc/apiwiki/domain/api/repository/ApiRepository.java b/src/main/java/com/umc/apiwiki/domain/api/repository/ApiRepository.java new file mode 100644 index 0000000..b702896 --- /dev/null +++ b/src/main/java/com/umc/apiwiki/domain/api/repository/ApiRepository.java @@ -0,0 +1,7 @@ +package com.umc.apiwiki.domain.api.repository; + +import com.umc.apiwiki.domain.api.entity.Api; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApiRepository extends JpaRepository{ +} diff --git a/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiQueryService.java b/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiDetailQueryService.java similarity index 98% rename from src/main/java/com/umc/apiwiki/domain/api/service/query/ApiQueryService.java rename to src/main/java/com/umc/apiwiki/domain/api/service/query/ApiDetailQueryService.java index 4b12a7b..7527f8c 100644 --- a/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiQueryService.java +++ b/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiDetailQueryService.java @@ -14,7 +14,7 @@ @Service @Transactional(readOnly = true) -public class ApiQueryService { +public class ApiDetailQueryService { @PersistenceContext private EntityManager em; diff --git a/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiSearchQueryService.java b/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiSearchQueryService.java new file mode 100644 index 0000000..1080c58 --- /dev/null +++ b/src/main/java/com/umc/apiwiki/domain/api/service/query/ApiSearchQueryService.java @@ -0,0 +1,149 @@ +package com.umc.apiwiki.domain.api.service.query; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.umc.apiwiki.domain.api.dto.ApiDTO; +import com.umc.apiwiki.domain.api.entity.QApi; +import com.umc.apiwiki.domain.api.entity.QApiCategoriesMap; +import com.umc.apiwiki.domain.api.enums.*; +import com.umc.apiwiki.domain.community.entity.review.QApiReview; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.querydsl.core.types.dsl.Expressions; + + +import java.math.BigDecimal; +import java.util.List; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApiSearchQueryService { + + private final JPAQueryFactory queryFactory; + + public Page searchApis( + int page, + Integer size, + Long categoryId, + String q, + ApiSortType sort, + SortDirection direction, + ProviderCompany providerCompany, + AuthType authType, + PricingType pricingType, + BigDecimal minRating + ) { + + QApi api = QApi.api; + QApiReview review = QApiReview.apiReview; + QApiCategoriesMap map = QApiCategoriesMap.apiCategoriesMap; + + BooleanBuilder builder = new BooleanBuilder(); + + // 필터 + if (providerCompany != null) + builder.and(api.providerCompany.eq(providerCompany)); + + if (authType != null) + builder.and(api.authType.eq(authType)); + + if (pricingType != null) + builder.and(api.pricingType.eq(pricingType)); + + if (minRating != null) + builder.and(api.avgRating.goe(minRating)); + + // 검색 (CLOB 제외) + if (q != null && !q.isBlank()) { + String keyword = "%" + q.toLowerCase() + "%"; + + builder.and( + api.name.lower().like(keyword) + .or(api.summary.lower().like(keyword)) + .or(Expressions.stringTemplate( + "lower({0})", + api.providerCompany.stringValue() + ).like(keyword)) + .or(Expressions.stringTemplate( + "lower({0})", + api.authType.stringValue() + ).like(keyword)) + .or(Expressions.stringTemplate( + "lower({0})", + api.pricingType.stringValue() + ).like(keyword)) + ); + } + + int pageSize = (size != null) ? size : 16; + Pageable pageable = PageRequest.of(page, pageSize); + + var query = queryFactory + .select(Projections.constructor( + ApiDTO.ApiPreview.class, + api.id, + api.name, + api.summary, + api.avgRating.coalesce(BigDecimal.ZERO), + review.id.count(), + api.viewCounts.coalesce(0L), + api.pricingType, + api.authType, + api.providerCompany + )) + .from(api) + .leftJoin(review).on(review.api.id.eq(api.id)); + + // 카테고리 필터 + if (categoryId != null) { + query.join(map) + .on(map.api.id.eq(api.id)) + .where(map.category.id.eq(categoryId)); + } + + // 정렬 처리 + + boolean desc = direction == SortDirection.DESC; + + OrderSpecifier order = + switch (sort) { + + case POPULAR -> + desc ? api.viewCounts.desc() + : api.viewCounts.asc(); + + case MOST_REVIEWED -> + desc ? review.id.count().desc() + : review.id.count().asc(); + + case LATEST -> + desc ? api.createdAt.desc() + : api.createdAt.asc(); + }; + + List content = + query + .where(builder) + .groupBy(api.id) + .orderBy(order) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = + queryFactory + .select(api.count()) + .from(api) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0 : total); + } +} + diff --git a/src/main/java/com/umc/apiwiki/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/umc/apiwiki/global/apiPayload/code/GeneralErrorCode.java index baf4ee1..ad7fc63 100644 --- a/src/main/java/com/umc/apiwiki/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/umc/apiwiki/global/apiPayload/code/GeneralErrorCode.java @@ -39,7 +39,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ INVALID_API_FILTER(HttpStatus.BAD_REQUEST, "API4003", "유효하지 않은 필터 또는 정렬 조건입니다."), API_NOT_PROCESSABLE(HttpStatus.BAD_REQUEST, "API4004", "요청을 처리할 수 없습니다."), API_FORBIDDEN(HttpStatus.FORBIDDEN, "API4005", "요청에 대한 권한이 없습니다."); - ; + private final HttpStatus status; private final String code; diff --git a/src/main/java/com/umc/apiwiki/global/config/QuerydslConfig.java b/src/main/java/com/umc/apiwiki/global/config/QuerydslConfig.java new file mode 100644 index 0000000..0977bf3 --- /dev/null +++ b/src/main/java/com/umc/apiwiki/global/config/QuerydslConfig.java @@ -0,0 +1,18 @@ +package com.umc.apiwiki.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +}