Skip to content

Commit 1097d88

Browse files
authored
Merge pull request #96 from sohyu-na/feature/#2-Bookstore
feat(#23): 서점 해시태그 추출 및 조회 api
2 parents 9901cdd + 32e233d commit 1097d88

7 files changed

Lines changed: 237 additions & 0 deletions

File tree

src/main/java/com/capstone/bszip/Bookstore/controller/BookstoreController.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.capstone.bszip.Bookstore.domain.BookstoreCategory;
77
import com.capstone.bszip.Bookstore.service.BookstoreReviewService;
88
import com.capstone.bszip.Bookstore.service.BookstoreService;
9+
import com.capstone.bszip.Bookstore.service.HashtagService;
910
import com.capstone.bszip.Bookstore.service.dto.*;
1011
import com.capstone.bszip.Member.domain.Member;
1112
import com.capstone.bszip.commonDto.ErrorResponse;
@@ -42,6 +43,7 @@ public class BookstoreController {
4243

4344
private final BookstoreService bookstoreService;
4445
private final BookstoreReviewService bookstoreReviewService;
46+
private final HashtagService hashtagService;
4547
private final IndepBookService indepBookService;
4648

4749
@Operation(summary = "서점 검색", description = "검색창에서 서점을 이름,주소로 검색합니다.")
@@ -366,4 +368,48 @@ public ResponseEntity<?> getTrendingBookstores(){
366368
}
367369

368370
}
371+
@Operation(
372+
summary = "서점 해시태그 목록 조회",
373+
description = "서점 해시태그 중 랜덤으로 10개를 반환합니다. 각 해시태그는 태그명과 연결된 서점 ID를 포함합니다."
374+
)
375+
@ApiResponses(value = {
376+
@ApiResponse(responseCode = "200", description = "추천 해시태그 목록 조회 성공",
377+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = SuccessResponse.class))),
378+
@ApiResponse(responseCode = "404", description = "추천 해시태그 정보가 존재하지 않음",
379+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
380+
@ApiResponse(responseCode = "500", description = "서버 오류 발생",
381+
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
382+
})
383+
@GetMapping("/hashtag")
384+
public ResponseEntity<?> getRandomHashtags(@RequestParam(defaultValue = "10") int count) {
385+
try {
386+
List<HashtagResponse> hashtags = hashtagService.getRandomHashtagsWithBookstoreId(count);
387+
if (hashtags == null || hashtags.isEmpty()) {
388+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
389+
.body(ErrorResponse.builder()
390+
.result(false)
391+
.status(HttpStatus.NOT_FOUND.value())
392+
.message("추천 해시태그 정보가 존재하지 않습니다")
393+
.detail("DB에 추천 해시태그가 없습니다.")
394+
.build());
395+
}
396+
return ResponseEntity.ok(
397+
SuccessResponse.<List<HashtagResponse>>builder()
398+
.result(true)
399+
.status(HttpStatus.OK.value())
400+
.message("추천 해시태그 목록 조회 성공")
401+
.data(hashtags)
402+
.build()
403+
);
404+
} catch (Exception e) {
405+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
406+
.body(ErrorResponse.builder()
407+
.result(false)
408+
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
409+
.message("서버 오류가 발생했습니다")
410+
.detail(e.getMessage())
411+
.build());
412+
}
413+
}
414+
369415
}

src/main/java/com/capstone/bszip/Bookstore/domain/Bookstore.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,13 @@ public void updateRating(double newRating) {
6565

6666
@OneToMany(mappedBy = "bookstore", cascade = CascadeType.ALL, orphanRemoval = true)
6767
private List<BookstoreReview> bookstoreReviews;
68+
69+
@OneToOne(mappedBy = "bookstore", cascade = CascadeType.ALL, orphanRemoval = true)
70+
private Hashtag hashtag;
71+
72+
@Builder
73+
public Bookstore(Long bookstoreId, Hashtag hashtag) {
74+
this.bookstoreId = bookstoreId;
75+
this.hashtag = hashtag;
76+
}
6877
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.capstone.bszip.Bookstore.domain;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Entity
10+
@Getter
11+
@Builder
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
public class Hashtag {
15+
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
16+
private Long id;
17+
18+
@OneToOne
19+
@JoinColumn(name = "bookstore_id", unique = true) // 외래 키 + 유니크 제약
20+
private Bookstore bookstore;
21+
22+
@Column(unique = true, nullable = false)
23+
private String tag;
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.capstone.bszip.Bookstore.repository;
2+
3+
import com.capstone.bszip.Bookstore.domain.Hashtag;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
7+
8+
import java.util.List;
9+
import java.util.Optional;
10+
11+
public interface HashtagRepository extends JpaRepository<Hashtag,Long> {
12+
boolean existsByTag(String tag);
13+
14+
Optional<Hashtag> findByTag(String tag);
15+
16+
@Query(value = "SELECT * FROM hashtag ORDER BY RAND() LIMIT :limit", nativeQuery = true)
17+
List<Hashtag> findRandomHashtags(@Param("limit") int limit);
18+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.capstone.bszip.Bookstore.service;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.http.*;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.web.client.RestTemplate;
9+
10+
import java.util.ArrayList;
11+
import java.util.HashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
import java.util.regex.Matcher;
15+
import java.util.regex.Pattern;
16+
17+
@Service
18+
public class HashtagExtractorService {
19+
20+
private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
21+
private static final RestTemplate restTemplate = new RestTemplate();
22+
@Value("${openai.api.key}")
23+
private String API_KEY;
24+
25+
public String extractHashtag(String description) {
26+
System.out.println(description);
27+
String prompt = String.format(
28+
"아래 서점 설명을 참고해서, 이전에 추출한 해시태그와 중복되지 않는, 새로운 해시태그 한 개만 추출해줘. " +
29+
"북스테이,북카페,서적 은 따로 저장하고 있으니 제외해줘 " +
30+
"설명: \"%s\" 결과는 해시태그 한 개만 반환해줘. 예시: #고양이", description);
31+
32+
ObjectMapper mapper = new ObjectMapper();
33+
try {
34+
Map<String, Object> requestMap = new HashMap<>();
35+
requestMap.put("model", "gpt-4");
36+
37+
List<Map<String, String>> messages = new ArrayList<>();
38+
Map<String, String> message = new HashMap<>();
39+
message.put("role", "user");
40+
message.put("content", prompt); // 이스케이프 자동 처리
41+
42+
messages.add(message);
43+
requestMap.put("messages", messages);
44+
45+
String requestBody = mapper.writeValueAsString(requestMap);
46+
47+
HttpHeaders headers = new HttpHeaders();
48+
headers.setBearerAuth(API_KEY);
49+
headers.setContentType(MediaType.APPLICATION_JSON);
50+
51+
HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
52+
53+
ResponseEntity<String> response = restTemplate.exchange(OPENAI_API_URL, HttpMethod.POST, entity, String.class);
54+
String content = response.getBody();
55+
System.out.println(content);
56+
Pattern pattern = Pattern.compile("#[\\w가-힣]+");
57+
Matcher matcher = pattern.matcher(content);
58+
if (matcher.find()) {
59+
return matcher.group();
60+
}
61+
return null;
62+
} catch (JsonProcessingException e) {
63+
return null;
64+
}
65+
}
66+
}
67+
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.capstone.bszip.Bookstore.service;
2+
3+
import com.capstone.bszip.Bookstore.domain.Bookstore;
4+
import com.capstone.bszip.Bookstore.domain.Hashtag;
5+
import com.capstone.bszip.Bookstore.repository.BookstoreRepository;
6+
import com.capstone.bszip.Bookstore.repository.HashtagRepository;
7+
import com.capstone.bszip.Bookstore.service.dto.HashtagResponse;
8+
import jakarta.annotation.PostConstruct;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.util.List;
14+
import java.util.stream.Collectors;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class HashtagService {
19+
private final BookstoreRepository bookstoreRepository;
20+
private final HashtagRepository hashtagRepository;
21+
private final HashtagExtractorService hashtagExtractorService;
22+
23+
@Transactional
24+
public void migrateAllBookstoresToHashtags(){
25+
List<Bookstore> bookstores = bookstoreRepository.findAll();
26+
for(Bookstore bookstore : bookstores){
27+
String description = bookstore.getDescription();
28+
if(description!=null && !description.isEmpty()){
29+
//해시태그 추출
30+
String extractedTag = hashtagExtractorService.extractHashtag(description);
31+
32+
if (extractedTag != null && !extractedTag.isEmpty()) {
33+
//존재하지 않는 경우
34+
if (!hashtagRepository.existsByTag(extractedTag)) {
35+
Hashtag hashtag = Hashtag.builder()
36+
.bookstore(bookstore)
37+
.tag(extractedTag)
38+
.build();
39+
hashtagRepository.save(hashtag);
40+
}
41+
}
42+
}
43+
}
44+
}
45+
@PostConstruct
46+
public void executeHashtagMigration() {
47+
migrateAllBookstoresToHashtags();
48+
System.out.println("모든 서점의 해시태그 추출 및 저장이 완료되었습니다.");
49+
}
50+
51+
@Transactional(readOnly = true)
52+
public List<HashtagResponse> getRandomHashtagsWithBookstoreId(int count) {
53+
return hashtagRepository.findRandomHashtags(count)
54+
.stream()
55+
.map(hashtag -> new HashtagResponse(
56+
hashtag.getTag(),
57+
hashtag.getBookstore().getBookstoreId()))
58+
.collect(Collectors.toList());
59+
}
60+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.capstone.bszip.Bookstore.service.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
7+
@Getter
8+
@AllArgsConstructor
9+
@NoArgsConstructor
10+
public class HashtagResponse {
11+
private String tag;
12+
private Long bookstoreId;
13+
}

0 commit comments

Comments
 (0)