Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ public RsData<List<ConcertItem>> getConcertsListByArtist(
return RsData.success(concertService.getArtistConcertList(artistId,type,pageable));
}

@Operation(summary = "좋아요 기반 추천 공연 목록", description = """
<h2>현재 사용자가 좋아요 한 공연들을 기반으로 앞으로 올 공연들을 추천합니다.</h2><hr/>
좋아요 한 공연의 제목을 어절 단위로 잘라, 중복으로 나타나는 어절에 가중치를 부여합니다.<br/>
이렇게 구성된 어절을 포함하고 있는 제목들을 전부 가져옵니다.<br/>
자카드 유사도를 통해 유사도가 높은 공연을 가져오되, 가중치가 높은 어절을 포함하는 공연이 먼저 보이게끔 정렬합니다.<br/>
<strong>좋아요 한 공연이 없거나, 어절을 포함한 공연이 없을 경우 빈 배열이 반환됩니다.</strong>
""")
@GetMapping("recommendByLike")
public RsData<List<ConcertItem>> getRecommendByLike(){
User user = rq.getUser();
List<ConcertItem> likedList = concertService.concertsRecommendByLike(user);
return RsData.success(likedList);
}

@Operation(summary = "좋아요 한 공연 조회", description = "좋아요를 누른 공연에 대한 목록을 조회합니다. 저장 날짜를 기준으로 내림차순 정렬로 표시합니다.(최신으로 추가된 목록순입니다.)")
@GetMapping("likedConcertList")
public RsData<List<ConcertItem>> getLikedConcertList(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,12 @@ public Map<Long, Integer> getCachedViewCountMap() {

// 공연의 조회수 조회
public Long getCachedViewCount(Long concertId) {
Long viewCount = Long.valueOf(redisTemplate.opsForHash()
.get(
CONCERTS_VIEW_COUNTS,
concertId.toString()
)
return Long.valueOf(Objects.requireNonNull(redisTemplate.opsForHash()
.get(
CONCERTS_VIEW_COUNTS,
concertId.toString()
))
.toString());

return viewCount;
}

// 캐시된 조회수 삭제
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ public class ConcertSearchRedisTemplate {
private final RedisTemplate<String,String> redisTemplate;

private static final String INDEX_KEY = "index:";
private static final String DATE_KEY = "data:";
private static final String CONCERT_ID_KEY = "concertName:";
private static final String CONCERT_ID_KEY = "concertId:";

public void addAllWordsWithWeight(List<WeightedString> weightedStrings) {
// PipeLine 사용해서 한번에 처리 -> IO 시간 감소
Expand All @@ -32,18 +31,18 @@ public void addAllWordsWithWeight(List<WeightedString> weightedStrings) {
String word = weightedString.getWord();
double score = weightedString.getScore();

// 개별 문자들에 대한 키-값 설정
connection.commands().set((CONCERT_ID_KEY + word).getBytes(StandardCharsets.UTF_8), id.getBytes(StandardCharsets.UTF_8));
// 검색 결과를 ID - 제목 쌍으로 저장
connection.commands().set((CONCERT_ID_KEY + id).getBytes(StandardCharsets.UTF_8),word.getBytes(StandardCharsets.UTF_8));

// 역 인덱싱
for(int i = 0 ;i<word.length();i++){
for(int j = i+1;j<= word.length();j++ ){
String subWord = word.substring(i,j);

// 공백은 검색어 인덱스에서 제외
if(subWord.isBlank()) continue;

// 서브 문자열을 인덱스 키, 값은 ID 값으로 해서 저장
byte[] indexKey = (INDEX_KEY + subWord).getBytes(StandardCharsets.UTF_8);
connection.zAdd(indexKey,score,word.getBytes(StandardCharsets.UTF_8));
connection.zAdd(indexKey,score,id.getBytes(StandardCharsets.UTF_8));
}
}
}
Expand All @@ -54,17 +53,18 @@ public void addAllWordsWithWeight(List<WeightedString> weightedStrings) {
public List<AutoCompleteItem> getAutoCompleteWord(String keyword, int start, int end) {
Set<String> results = redisTemplate.opsForZSet().reverseRange(INDEX_KEY + keyword, start, end);
List<String> resultList = new ArrayList<>(results);
return resultList.stream().map(name ->{
Long id = Long.valueOf(redisTemplate.opsForValue().get(CONCERT_ID_KEY + name));
return new AutoCompleteItem(name,id);
return resultList.stream().map(id ->{
String name = redisTemplate.opsForValue().get(CONCERT_ID_KEY + id);
return new AutoCompleteItem(name,Long.valueOf(id));
}).toList();
}

public void deleteAutoCompleteWords() {
Set<String> keys = redisTemplate.keys("index:*");
Set<String> datas = redisTemplate.keys("data:*");
if (keys != null || !keys.isEmpty()) redisTemplate.delete(keys);
if (datas != null || !keys.isEmpty()) redisTemplate.delete(datas);
Set<String> concertIdKeys = redisTemplate.keys("concertId:*");
redisTemplate.delete(keys);
redisTemplate.delete(concertIdKeys);
log.info("자동 검색 키워드 삭제: " + keys.size() + "개의 키워드, " + concertIdKeys.size() + "개의 제목이 삭제되었습니다.");
}

public Long getConcertIdByName(String concertName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -302,9 +303,7 @@ public List<ConcertItem> recommendSimilarConcerts(long concertId) {
// 유사한 제목을 가지는 공연 추천
public List<ConcertItem> recommendSimilarTitleConcerts(long concertId) {
Concert concert = findConcertByConcertId(concertId);
String name = concert.getName();
String match = "[^ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9\\s]";
name = name.replaceAll(match, "");
String name = simplifyKeyword(concert.getName());
log.info("name: " + name);
String[] words = name.split(" ");
List<AutoCompleteItem> result = new ArrayList<>();
Expand All @@ -327,6 +326,78 @@ public List<ConcertItem> recommendSimilarTitleConcerts(long concertId) {
return concertItemList;
}

// 좋아요 한 제목에서 중복으로 나타나는 단어에 가중치 부여 후 자카드 유사도에 가점 부여하여 정렬 후 공연 추천
public List<ConcertItem> concertsRecommendByLike(User user){
Pageable pageable = PageRequest.of(0, 100);
List<ConcertItem> likeList = concertRepository.getLikedConcertsList(pageable,user.getId());
if(likeList.isEmpty()) return new ArrayList<>(); // 좋아요 한 공연이 없을 경우 빈 공연 반환
Map<String, WeightedBits> weightedBitsMap = new HashMap<>();
Set<Long> idSet = new HashSet<>();
for(ConcertItem item : likeList){
idSet.add(item.getId());
String name = item.getName();
String simpleName = simplifyKeyword(name);
String[] words = simpleName.split(" ");

for (String word : words) { // 단어별로 가중치 적용
WeightedBits weightedBits = weightedBitsMap.getOrDefault(word, new WeightedBits(word,0));
weightedBits.plusWeight();
weightedBitsMap.put(word, weightedBits);
}
}

List<AutoCompleteItem> result = new ArrayList<>();
for (String word : weightedBitsMap.keySet()) {
if(word.isEmpty()) continue;
log.info("word: " + word);
result.addAll(concertSearchRedisTemplate.getAutoCompleteWord(word,0,6));
}

Set<Long> resultIdSet = new HashSet<>();
for (AutoCompleteItem item : result) {
if(idSet.contains(item.getId())) continue; // 찜한 목록 제거
resultIdSet.add(item.getId()); // 중복 제거
}

List<Long> idList = new ArrayList<>();
for (Long id : resultIdSet) {
idList.add(id);
}

List<ConcertItem> concertItemList = concertRepository.getConcertItemsInIdList(idList,LocalDate.now());
concertItemList.sort(Comparator.comparingDouble(
ci -> jaccardSimilarityWithWeight(weightedBitsMap,ci.getName().split(" "))
));
return concertItemList;
}

private double jaccardSimilarityWithWeight(Map<String,WeightedBits> weightedBitsMap, String[] words) {
int intersection =0;
for (String word : words) {
if(word.isEmpty()) continue;
WeightedBits weightedBits = weightedBitsMap.get(word);
if(weightedBits == null) continue;
intersection += weightedBits.weight;
}
int union = weightedBitsMap.size() + words.length;
return (double)union/intersection;
}

private class WeightedBits{
String bit;
int weight;

public WeightedBits(String bit, int weight) {
this.bit = bit;
this.weight = weight;
}

public WeightedBits plusWeight(){
this.weight++;
return this;
}
}

private double jaccardSimilarity(String[] origin , String[] target) {
Set<String> union = new HashSet<>();
Set<String> intersection = new HashSet<>();
Expand All @@ -342,6 +413,13 @@ private double jaccardSimilarity(String[] origin , String[] target) {
return (double) union.size() / intersection.size();
}

// 특수 문자를 제거합니다.
private static String simplifyKeyword(String name) {
String match = "[^ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9\\s]";
name = name.replaceAll(match, "");
return name;
}

@Transactional(readOnly = true)
public void validateConcertExists(Long concertId) {
concertRepository.findById(concertId)
Expand Down
Loading