diff --git a/src/main/java/com/example/RealMatch/brand/presentation/controller/BrandController.java b/src/main/java/com/example/RealMatch/brand/presentation/controller/BrandController.java index 9400b707..c216c1c9 100644 --- a/src/main/java/com/example/RealMatch/brand/presentation/controller/BrandController.java +++ b/src/main/java/com/example/RealMatch/brand/presentation/controller/BrandController.java @@ -15,6 +15,7 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.example.RealMatch.brand.application.service.BrandService; @@ -34,6 +35,10 @@ import com.example.RealMatch.global.config.jwt.CustomUserDetails; import com.example.RealMatch.global.presentation.CustomResponse; import com.example.RealMatch.global.presentation.code.GeneralSuccessCode; +import com.example.RealMatch.match.application.service.MatchService; +import com.example.RealMatch.match.domain.entity.enums.BrandSortType; +import com.example.RealMatch.match.domain.entity.enums.CategoryType; +import com.example.RealMatch.match.presentation.dto.response.MatchBrandResponseDto; import lombok.RequiredArgsConstructor; @@ -43,6 +48,7 @@ public class BrandController implements BrandSwagger { private final BrandService brandService; + private final MatchService matchService; // ******** // // 브랜드 조회 // @@ -58,6 +64,21 @@ public CustomResponse> getBrandDetail( return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, Collections.singletonList(result)); } + @Override + @GetMapping("/search") + public CustomResponse searchBrands( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String title, + @RequestParam(defaultValue = "MATCH_SCORE") BrandSortType sortBy, + @RequestParam(defaultValue = "ALL") CategoryType category, + @RequestParam(required = false) List tags, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + String userId = String.valueOf(userDetails.getUserId()); + MatchBrandResponseDto result = matchService.searchMatchingBrands(userId, title, sortBy, category, tags, page, size); + return CustomResponse.ok(result); + } + @Override @PostMapping("/{brandId}/like") public CustomResponse> likeBrand( diff --git a/src/main/java/com/example/RealMatch/brand/presentation/swagger/BrandSwagger.java b/src/main/java/com/example/RealMatch/brand/presentation/swagger/BrandSwagger.java index f3fba371..4d717b91 100644 --- a/src/main/java/com/example/RealMatch/brand/presentation/swagger/BrandSwagger.java +++ b/src/main/java/com/example/RealMatch/brand/presentation/swagger/BrandSwagger.java @@ -23,6 +23,9 @@ import com.example.RealMatch.brand.presentation.dto.response.SponsorProductListResponseDto; import com.example.RealMatch.global.config.jwt.CustomUserDetails; import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.match.domain.entity.enums.BrandSortType; +import com.example.RealMatch.match.domain.entity.enums.CategoryType; +import com.example.RealMatch.match.presentation.dto.response.MatchBrandResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -88,6 +91,28 @@ CustomResponse> getBrandDetail( @Parameter(hidden = true) CustomUserDetails principal ); + @Operation(summary = "브랜드 검색 by 이예림", + description = """ + JWT 토큰의 사용자 ID를 기반으로 매칭률이 높은 브랜드 목록을 검색합니다. + **검색**: title을 입력하면 브랜드명(title)만 검색합니다. + 정렬 옵션: MATCH_SCORE(매칭률 순), POPULARITY(인기순), NEWEST(신규순) + 카테고리 필터: ALL(전체), FASHION(패션), BEAUTY(뷰티) + 태그 필터: 뷰티/패션 관련 태그로 필터링 + 페이지네이션: page(0부터 시작), size(기본 20) + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "브랜드 검색 성공") + }) + CustomResponse searchBrands( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "브랜드명 검색어 (브랜드명 title만 검색)") String title, + @Parameter(description = "정렬 기준 (MATCH_SCORE, POPULARITY, NEWEST)") BrandSortType sortBy, + @Parameter(description = "카테고리 필터 (ALL, FASHION, BEAUTY)") CategoryType category, + @Parameter(description = "태그 필터 (예: 스킨케어, 미니멀)") List tags, + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기 (기본 20)") int size + ); + @Operation(summary = "브랜드 좋아요 토글 by 이예림", description = "브랜드 ID로 좋아요를 추가하거나 취소합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "토글 성공"), @@ -177,7 +202,7 @@ ResponseEntity getBrandIdByUserId( @Parameter(description = "조회할 유저의 ID", required = true) @PathVariable Long userId ); - @Operation(summary = "브랜드 개별 요약 조회 API", description = "사진 클릭 시 하단에 노출되는 브랜드의 기본 정보(이미지, 이름, 태그, 매칭률)를 조회합니다.") + @Operation(summary = "브랜드 개별 요약 조회 API by 이예림", description = "사진 클릭 시 하단에 노출되는 브랜드의 기본 정보(이미지, 이름, 태그, 매칭률)를 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = BrandSimpleDetailResponse.class))), diff --git a/src/main/java/com/example/RealMatch/match/application/service/MatchService.java b/src/main/java/com/example/RealMatch/match/application/service/MatchService.java index 2a602ab9..87c073ba 100644 --- a/src/main/java/com/example/RealMatch/match/application/service/MatchService.java +++ b/src/main/java/com/example/RealMatch/match/application/service/MatchService.java @@ -16,6 +16,16 @@ public interface MatchService { MatchBrandResponseDto getMatchingBrands(String userId, BrandSortType sortBy, CategoryType category, List tags); + MatchBrandResponseDto searchMatchingBrands( + String userId, + String title, + BrandSortType sortBy, + CategoryType category, + List tags, + int page, + int size + ); + MatchCampaignResponseDto getMatchingCampaigns( String userId, String keyword, diff --git a/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java b/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java index a944da09..3639dbba 100644 --- a/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java +++ b/src/main/java/com/example/RealMatch/match/application/service/MatchServiceImpl.java @@ -17,6 +17,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import com.example.RealMatch.brand.domain.entity.Brand; import com.example.RealMatch.brand.domain.entity.BrandDescribeTag; @@ -476,16 +477,15 @@ public MatchBrandResponseDto getMatchingBrands(String userId, BrandSortType sort List brandIds = brandHistories.stream() .map(h -> h.getBrand().getId()) .toList(); - Map> brandDescribeTagMap = brandIds.stream() - .collect(Collectors.toMap( - brandId -> brandId, - brandId -> brandDescribeTagRepository.findAllByBrandId(brandId).stream() - .map(BrandDescribeTag::getBrandDescribeTag) - .toList() + Map> brandDescribeTagMap = brandDescribeTagRepository.findAllByBrandIdIn(brandIds).stream() + .collect(Collectors.groupingBy( + tag -> tag.getBrand().getId(), + Collectors.mapping(BrandDescribeTag::getBrandDescribeTag, Collectors.toList()) )); List matchedBrands = brandHistories.stream() .filter(history -> filterBrandByCategory(history.getBrand(), category)) + .filter(history -> filterBrandByTags(history.getBrand().getId(), tags, brandDescribeTagMap)) .sorted(getBrandHistoryComparator(sortBy, brandLikeCountMap)) .limit(TOP_MATCH_COUNT) .map(history -> toMatchBrandDtoFromHistory(history, likedBrandIds, recruitingBrandIds, brandDescribeTagMap)) @@ -497,6 +497,58 @@ public MatchBrandResponseDto getMatchingBrands(String userId, BrandSortType sort .build(); } + @Override + public MatchBrandResponseDto searchMatchingBrands( + String userId, + String title, + BrandSortType sortBy, + CategoryType category, + List tags, + int page, + int size + ) { + Long userIdLong = Long.parseLong(userId); + + int safePage = Math.max(page, 0); + int safeSize = size <= 0 ? 20 : size; + PageRequest pageable = PageRequest.of(safePage, safeSize); + + Page historyPage = matchBrandHistoryRepository + .searchBrands(userIdLong, title, category, sortBy, tags, pageable); + + if (historyPage.isEmpty()) { + LOG.warn("No match brand history found in DB. userId={}", userId); + return MatchBrandResponseDto.builder() + .count(0) + .brands(List.of()) + .build(); + } + + Set likedBrandIds = brandLikeRepository.findByUserId(userIdLong).stream() + .map(like -> like.getBrand().getId()) + .collect(Collectors.toSet()); + + Set recruitingBrandIds = getRecruitingBrandIds(); + + List pageBrandIds = historyPage.getContent().stream() + .map(h -> h.getBrand().getId()) + .toList(); + Map> brandDescribeTagMap = brandDescribeTagRepository.findAllByBrandIdIn(pageBrandIds).stream() + .collect(Collectors.groupingBy( + tag -> tag.getBrand().getId(), + Collectors.mapping(BrandDescribeTag::getBrandDescribeTag, Collectors.toList()) + )); + + List pagedBrands = historyPage.getContent().stream() + .map(history -> toMatchBrandDtoFromHistory(history, likedBrandIds, recruitingBrandIds, brandDescribeTagMap)) + .toList(); + + return MatchBrandResponseDto.builder() + .count((int) historyPage.getTotalElements()) + .brands(pagedBrands) + .build(); + } + @Override public MatchCampaignResponseDto getMatchingCampaigns( String userId, @@ -610,6 +662,37 @@ private Comparator getBrandHistoryComparator(BrandSortType so }; } + private boolean filterBrandByTitle(Brand brand, String title) { + if (!StringUtils.hasText(title)) { + return true; + } + String brandName = brand != null ? brand.getBrandName() : null; + if (!StringUtils.hasText(brandName)) { + return false; + } + return brandName.toLowerCase().contains(title.trim().toLowerCase()); + } + + private boolean filterBrandByTags(Long brandId, List tags, Map> brandDescribeTagMap) { + if (tags == null || tags.isEmpty()) { + return true; + } + List brandTags = brandDescribeTagMap.getOrDefault(brandId, List.of()); + if (brandTags.isEmpty()) { + return false; + } + + List normalizedBrandTags = brandTags.stream() + .filter(StringUtils::hasText) + .map(tag -> tag.trim().toLowerCase()) + .toList(); + + return tags.stream() + .filter(StringUtils::hasText) + .map(tag -> tag.trim().toLowerCase()) + .anyMatch(normalizedBrandTags::contains); + } + private MatchBrandResponseDto.BrandDto toMatchBrandDtoFromHistory( MatchBrandHistory history, Set likedBrandIds, Set recruitingBrandIds, Map> brandDescribeTagMap) { diff --git a/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepository.java b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepository.java index 2036a5e7..6faef45b 100644 --- a/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepository.java +++ b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepository.java @@ -10,7 +10,7 @@ import com.example.RealMatch.match.domain.entity.MatchBrandHistory; -public interface MatchBrandHistoryRepository extends JpaRepository { +public interface MatchBrandHistoryRepository extends JpaRepository, MatchBrandHistoryRepositoryCustom { List findByUserId(Long userId); diff --git a/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustom.java b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustom.java new file mode 100644 index 00000000..377c98a6 --- /dev/null +++ b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustom.java @@ -0,0 +1,29 @@ +package com.example.RealMatch.match.domain.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.RealMatch.match.domain.entity.MatchBrandHistory; +import com.example.RealMatch.match.domain.entity.enums.BrandSortType; +import com.example.RealMatch.match.domain.entity.enums.CategoryType; + +public interface MatchBrandHistoryRepositoryCustom { + + Page searchBrands( + Long userId, + String title, + CategoryType category, + BrandSortType sortBy, + List tags, + Pageable pageable + ); + + long countSearchBrands( + Long userId, + String title, + CategoryType category, + List tags + ); +} diff --git a/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustomImpl.java b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustomImpl.java new file mode 100644 index 00000000..a3a64387 --- /dev/null +++ b/src/main/java/com/example/RealMatch/match/domain/repository/MatchBrandHistoryRepositoryCustomImpl.java @@ -0,0 +1,147 @@ +package com.example.RealMatch.match.domain.repository; + +import static com.example.RealMatch.brand.domain.entity.QBrand.brand; +import static com.example.RealMatch.match.domain.entity.QMatchBrandHistory.matchBrandHistory; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import com.example.RealMatch.brand.domain.entity.QBrandDescribeTag; +import com.example.RealMatch.brand.domain.entity.QBrandLike; +import com.example.RealMatch.match.domain.entity.MatchBrandHistory; +import com.example.RealMatch.match.domain.entity.enums.BrandSortType; +import com.example.RealMatch.match.domain.entity.enums.CategoryType; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MatchBrandHistoryRepositoryCustomImpl implements MatchBrandHistoryRepositoryCustom { + + private static final QBrandDescribeTag BRAND_DESCRIBE_TAG = QBrandDescribeTag.brandDescribeTag1; + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchBrands( + Long userId, + String title, + CategoryType category, + BrandSortType sortBy, + List tags, + Pageable pageable + ) { + BooleanBuilder whereClause = buildWhereClause(userId, title, category, tags); + List> orderSpecifiers = buildOrderSpecifiers(sortBy); + + List content = queryFactory + .selectFrom(matchBrandHistory) + .join(matchBrandHistory.brand, brand).fetchJoin() + .where(whereClause) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = countSearchBrands(userId, title, category, tags); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public long countSearchBrands(Long userId, String title, CategoryType category, List tags) { + BooleanBuilder whereClause = buildWhereClause(userId, title, category, tags); + + Long count = queryFactory + .select(matchBrandHistory.count()) + .from(matchBrandHistory) + .join(matchBrandHistory.brand, brand) + .where(whereClause) + .fetchOne(); + + return count != null ? count : 0L; + } + + // *********** // + // 검색 조건 빌더 // + // *********** // + private BooleanBuilder buildWhereClause(Long userId, String title, CategoryType category, List tags) { + BooleanBuilder builder = new BooleanBuilder(); + + builder.and(matchBrandHistory.user.id.eq(userId)); + builder.and(matchBrandHistory.isDeprecated.eq(false)); + + if (StringUtils.hasText(title)) { + builder.and(brand.brandName.containsIgnoreCase(title.trim())); + } + + if (category != null && category != CategoryType.ALL) { + builder.and(brand.industryType.stringValue().equalsIgnoreCase(category.name())); + } + + if (tags != null && !tags.isEmpty()) { + List normalizedTags = tags.stream() + .filter(StringUtils::hasText) + .map(tag -> tag.trim().toLowerCase()) + .toList(); + + if (!normalizedTags.isEmpty()) { + StringExpression tagLower = BRAND_DESCRIBE_TAG.brandDescribeTag.lower(); + builder.and( + JPAExpressions + .selectOne() + .from(BRAND_DESCRIBE_TAG) + .where( + BRAND_DESCRIBE_TAG.brand.id.eq(brand.id), + tagLower.in(normalizedTags) + ) + .exists() + ); + } + } + + return builder; + } + + // *********** // + // 정렬 조건 빌더 // + // *********** // + private com.querydsl.core.types.Expression likeCountSubquery() { + QBrandLike likeSub = new QBrandLike("bl"); + return JPAExpressions + .select(likeSub.id.count()) + .from(likeSub) + .where(likeSub.brand.id.eq(brand.id)); + } + + private OrderSpecifier likeCountDesc() { + return new OrderSpecifier<>(Order.DESC, likeCountSubquery(), OrderSpecifier.NullHandling.NullsLast); + } + + private List> buildOrderSpecifiers(BrandSortType sortBy) { + if (sortBy == null) { + sortBy = BrandSortType.MATCH_SCORE; + } + + OrderSpecifier matchingRatioDesc = matchBrandHistory.matchingRatio.desc().nullsLast(); + OrderSpecifier brandIdDesc = brand.id.desc(); + OrderSpecifier popularityDesc = likeCountDesc(); + + return switch (sortBy) { + case POPULARITY -> List.of(popularityDesc, matchingRatioDesc, brandIdDesc); + case NEWEST -> List.of(brandIdDesc); + default -> List.of(matchingRatioDesc, popularityDesc, brandIdDesc); + }; + } +}