Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -43,6 +48,7 @@
public class BrandController implements BrandSwagger {

private final BrandService brandService;
private final MatchService matchService;

// ******** //
// 브랜드 조회 //
Expand All @@ -58,6 +64,21 @@ public CustomResponse<java.util.List<BrandDetailResponseDto>> getBrandDetail(
return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, Collections.singletonList(result));
}

@Override
@GetMapping("/search")
public CustomResponse<MatchBrandResponseDto> searchBrands(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "MATCH_SCORE") BrandSortType sortBy,
@RequestParam(defaultValue = "ALL") CategoryType category,
@RequestParam(required = false) List<String> 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<List<BrandLikeResponseDto>> likeBrand(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +91,28 @@ CustomResponse<java.util.List<BrandDetailResponseDto>> 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<MatchBrandResponseDto> 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<String> tags,
@Parameter(description = "페이지 번호 (0부터 시작)") int page,
@Parameter(description = "페이지 크기 (기본 20)") int size
);

@Operation(summary = "브랜드 좋아요 토글 by 이예림", description = "브랜드 ID로 좋아요를 추가하거나 취소합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토글 성공"),
Expand Down Expand Up @@ -177,7 +202,7 @@ ResponseEntity<Long> 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))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ public interface MatchService {

MatchBrandResponseDto getMatchingBrands(String userId, BrandSortType sortBy, CategoryType category, List<String> tags);

MatchBrandResponseDto searchMatchingBrands(
String userId,
String title,
BrandSortType sortBy,
CategoryType category,
List<String> tags,
int page,
int size
);

MatchCampaignResponseDto getMatchingCampaigns(
String userId,
String keyword,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -476,16 +477,15 @@ public MatchBrandResponseDto getMatchingBrands(String userId, BrandSortType sort
List<Long> brandIds = brandHistories.stream()
.map(h -> h.getBrand().getId())
.toList();
Map<Long, List<String>> brandDescribeTagMap = brandIds.stream()
.collect(Collectors.toMap(
brandId -> brandId,
brandId -> brandDescribeTagRepository.findAllByBrandId(brandId).stream()
.map(BrandDescribeTag::getBrandDescribeTag)
.toList()
Map<Long, List<String>> brandDescribeTagMap = brandDescribeTagRepository.findAllByBrandIdIn(brandIds).stream()
.collect(Collectors.groupingBy(
tag -> tag.getBrand().getId(),
Collectors.mapping(BrandDescribeTag::getBrandDescribeTag, Collectors.toList())
));

List<MatchBrandResponseDto.BrandDto> 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))
Expand All @@ -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<String> 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<MatchBrandHistory> 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<Long> likedBrandIds = brandLikeRepository.findByUserId(userIdLong).stream()
.map(like -> like.getBrand().getId())
.collect(Collectors.toSet());

Set<Long> recruitingBrandIds = getRecruitingBrandIds();

List<Long> pageBrandIds = historyPage.getContent().stream()
.map(h -> h.getBrand().getId())
.toList();
Map<Long, List<String>> brandDescribeTagMap = brandDescribeTagRepository.findAllByBrandIdIn(pageBrandIds).stream()
.collect(Collectors.groupingBy(
tag -> tag.getBrand().getId(),
Collectors.mapping(BrandDescribeTag::getBrandDescribeTag, Collectors.toList())
));

List<MatchBrandResponseDto.BrandDto> 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,
Expand Down Expand Up @@ -610,6 +662,37 @@ private Comparator<MatchBrandHistory> 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<String> tags, Map<Long, List<String>> brandDescribeTagMap) {
if (tags == null || tags.isEmpty()) {
return true;
}
List<String> brandTags = brandDescribeTagMap.getOrDefault(brandId, List.of());
if (brandTags.isEmpty()) {
return false;
}

List<String> 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);
}
Comment on lines +676 to +694
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

filterBrandByTags 메소드에서 태그를 비교하는 로직이 비효율적입니다. normalizedBrandTagsList인 상태에서 contains를 호출하면 내부적으로 반복문이 실행되어 O(N*M)의 시간 복잡도를 가집니다 (N: 필터 태그 수, M: 브랜드 태그 수).

normalizedBrandTagsSet으로 변환하여 contains 호출 시 O(1)의 시간 복잡도를 갖도록 하거나, Collections.disjoint를 사용하여 두 컬렉션에 공통 요소가 있는지 확인하는 것이 더 효율적이고 코드가 간결해집니다.

private boolean filterBrandByTags(Long brandId, List<String> tags, Map<Long, List<String>> brandDescribeTagMap) {
    if (tags == null || tags.isEmpty()) {
        return true;
    }
    List<String> brandTags = brandDescribeTagMap.getOrDefault(brandId, List.of());
    if (brandTags.isEmpty()) {
        return false;
    }

    List<String> normalizedFilterTags = tags.stream()
            .filter(StringUtils::hasText)
            .map(tag -> tag.trim().toLowerCase())
            .toList();

    List<String> normalizedBrandTags = brandTags.stream()
            .filter(StringUtils::hasText)
            .map(tag -> tag.trim().toLowerCase())
            .toList();

    return !java.util.Collections.disjoint(normalizedBrandTags, normalizedFilterTags);
}


private MatchBrandResponseDto.BrandDto toMatchBrandDtoFromHistory(
MatchBrandHistory history, Set<Long> likedBrandIds, Set<Long> recruitingBrandIds,
Map<Long, List<String>> brandDescribeTagMap) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import com.example.RealMatch.match.domain.entity.MatchBrandHistory;

public interface MatchBrandHistoryRepository extends JpaRepository<MatchBrandHistory, Long> {
public interface MatchBrandHistoryRepository extends JpaRepository<MatchBrandHistory, Long>, MatchBrandHistoryRepositoryCustom {

List<MatchBrandHistory> findByUserId(Long userId);

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MatchBrandHistory> searchBrands(
Long userId,
String title,
CategoryType category,
BrandSortType sortBy,
List<String> tags,
Pageable pageable
);

long countSearchBrands(
Long userId,
String title,
CategoryType category,
List<String> tags
);
}
Loading
Loading