Skip to content

Commit 9e6334d

Browse files
authored
Merge pull request #40 from Move-Log/feat/news-headline-create
[FEAT] 뉴스 헤드라인 추천 기능 구현
2 parents 07bbb03 + c44ee80 commit 9e6334d

File tree

12 files changed

+352
-5
lines changed

12 files changed

+352
-5
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.movelog.domain.gpt.application;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.web.reactive.function.client.WebClient;
10+
import reactor.core.publisher.Mono;
11+
12+
import java.util.HashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Slf4j
19+
public class GptService {
20+
21+
private final WebClient gptWebClient; // @Qualifier 제거하고 빈 이름에 맞는 생성자 주입
22+
@Value("${chatgpt.api-key}")
23+
private String apiKey;
24+
@Value("${chatgpt.model}")
25+
private String model;
26+
@Value("${chatgpt.max-tokens}")
27+
private Integer maxTokens;
28+
@Value("${chatgpt.temperature}")
29+
private Double temperature;
30+
@Value("${chatgpt.top-p}")
31+
private Double topP;
32+
33+
public Mono<JsonNode> callChatGpt(String prompt) {
34+
return gptWebClient.post()
35+
.uri("/chat/completions") // 경로에 대한 수정
36+
.header("Authorization", "Bearer " + apiKey)
37+
.contentType(MediaType.APPLICATION_JSON)
38+
.bodyValue(buildRequestBody(prompt))
39+
.retrieve()
40+
.bodyToMono(JsonNode.class)
41+
.doOnSuccess(response -> log.info("GPT API 응답 성공: {}", response))
42+
.doOnError(error -> log.error("GPT API 호출 중 오류 발생: {}", error.getMessage()))
43+
.onErrorResume(error -> {
44+
log.error("GPT API 호출 중 예외 처리 발생: {}", error.getMessage());
45+
return Mono.error(new RuntimeException("GPT API 호출 실패", error));
46+
});
47+
}
48+
49+
private Map<String, Object> buildRequestBody(String prompt) {
50+
Map<String, Object> bodyMap = new HashMap<>();
51+
bodyMap.put("model", model);
52+
bodyMap.put("max_tokens", maxTokens);
53+
bodyMap.put("temperature", temperature);
54+
bodyMap.put("top_p", topP);
55+
bodyMap.put("messages", List.of(
56+
Map.of(
57+
"role", "user",
58+
"content", prompt
59+
)
60+
));
61+
return bodyMap;
62+
}
63+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.movelog.domain.news.application;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.movelog.domain.gpt.application.GptService;
5+
import com.movelog.domain.news.dto.response.HeadLineRes;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.stereotype.Service;
9+
import reactor.core.publisher.Mono;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
@Slf4j
17+
public class HeadlineGeneratorService {
18+
19+
private final GptService gptService;
20+
21+
public List<HeadLineRes> generateHeadLine(String option, String verb, String noun) {
22+
// 프롬프트 생성
23+
String prompt = generatePrompt(option, verb, noun);
24+
25+
// 비동기 방식으로 GPT API 호출
26+
return gptService.callChatGpt(prompt)
27+
.flatMap(response -> {
28+
if (response == null) {
29+
return Mono.error(new RuntimeException("GPT API 응답이 없습니다."));
30+
}
31+
32+
// 응답 파싱하여 List<CreatePracticeRes> 반환
33+
List<HeadLineRes> practiceResList = parseResponse(response);
34+
return Mono.just(practiceResList);
35+
})
36+
.doOnError(error -> log.error("문제 생성 중 오류 발생: ", error)).block();
37+
}
38+
39+
40+
// 프롬프트 생성
41+
private String generatePrompt(String option, String verb, String noun) {
42+
return String.format(
43+
"다음 내용을 바탕으로 뉴스 헤드라인을 생성해주세요: %s\n" +
44+
"당신은 뉴스 헤드라인을 작성하는 역할이며, 다음 형식을 엄격히 따라 뉴스 헤드라인을 제공해 주세요:\n\n" +
45+
"이 뉴스 헤드라인의 목적은 사용자의 기록을 기반으로 약간의 재미 요소가 들어간 헤드라인을 제공하는 것입니다.\n" +
46+
"각 뉴스 헤드라인은 반드시 격식있는 어체로 구성되어야 하며, 반드시 올바른 어법을 준수해야 합니다.\n" +
47+
"각 헤드라인의 내용은 주어진 옵션, 동사, 명사를 기반으로 생성되어야 하며, 헤드라인 내용에 직접적인 내용이 들어가지 않아도 됩니다." +
48+
"뉴스 헤드라인은 반드시 3개의 후보를 제공해야 하며, 각 후보는 1개의 줄바꿈으로 구분됩니다.\n" +
49+
"각 헤드라인 내용의 구조는 반드시 쉼표로 나뉘어야 하며, 쉼표 이전의 내용에는 헤드라인 강조 옵션에 대한 내용이 포함되어야 합니다." +
50+
"쉼표 앞 뒤의 내용에 대한 글자 수는 각각 16자 이하여야 하며, 쉼표는 반드시 한글자로만 구성되어야 합니다.\n\n" +
51+
52+
"다음은 제공된 정보를 통해 출력해야 하는 뉴스 헤드라인의 출력 예시입니다:\n\n" +
53+
"꾸준한 노력 끝에 완성한 마라톤 기록, 도전의 의미는?\n오랜만의 첫 도전, 무엇이 그를 움직이게 했나?\n드디어 끊어낸 술, 운동으로 극복해내" +
54+
55+
"다음은 제공된 정보를 통해 출력해야 하는 뉴스 헤드라인의 출력 형식입니다:\n\n" +
56+
"첫번째 헤드라인 후보 내용\n두번째 헤드라인 후보 내용\n세번째 헤드라인 후보 내용\n" +
57+
58+
"반드시 뉴스 헤드라인 형식을 준수해 주세요. 정해진 형식을 따르지 않으면 응답을 처리할 수 없습니다." +
59+
"만약 결과 헤드라인 내용이 위 형식과 다르다면, 다시 요청하여 주세요.",
60+
option, verb, noun
61+
);
62+
}
63+
64+
// GPT 응답 파싱
65+
private List<HeadLineRes> parseResponse(JsonNode response) {
66+
List<HeadLineRes> headLineResList = new ArrayList<>();
67+
68+
// GPT 응답에서 질문, 답변, 해설 추출
69+
String textResponse = response.path("choices").get(0).path("message").path("content").asText().trim();
70+
log.info("Full Text Response: " + textResponse); // 전체 응답 확인
71+
72+
String[] headLines = textResponse.split("\n");
73+
// 헤드라인 생성 결과가 3개가 아닌 경우 예외 처리
74+
if (headLines.length != 3) {
75+
throw new RuntimeException("생성된 헤드라인이 3개가 아닙니다.");
76+
}
77+
78+
// 헤드라인 후보 파싱 결과를 리스트에 저장 후 응답 리턴
79+
for (String headLine : headLines) {
80+
headLineResList.add(HeadLineRes.builder().headLine(headLine).build());
81+
}
82+
83+
return headLineResList;
84+
}
85+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.movelog.domain.news.application;
2+
3+
import com.movelog.domain.news.dto.request.NewsHeadLineReq;
4+
import com.movelog.domain.news.dto.response.HeadLineRes;
5+
import com.movelog.domain.user.application.UserService;
6+
import com.movelog.domain.user.domain.User;
7+
import com.movelog.domain.user.domain.repository.UserRepository;
8+
import com.movelog.domain.user.exception.UserNotFoundException;
9+
import com.movelog.global.DefaultAssert;
10+
import com.movelog.global.config.security.token.UserPrincipal;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
import java.util.List;
17+
import java.util.Optional;
18+
19+
@Service
20+
@RequiredArgsConstructor
21+
@Transactional(readOnly = true)
22+
public class NewsService {
23+
private final HeadlineGeneratorService headlineGeneratorService;
24+
private final UserService userService;
25+
private final UserRepository userRepository;
26+
27+
public List<HeadLineRes> createHeadLine(UserPrincipal userPrincipal, NewsHeadLineReq newsHeadLineReq) {
28+
User user = validateUser(userPrincipal);
29+
// id가 5인 유저 정보(테스트용)
30+
// User user = userRepository.findById(5L).orElseThrow(UserNotFoundException::new);
31+
String option = newsHeadLineReq.getOption();
32+
String verb = newsHeadLineReq.getVerb();
33+
String noun = newsHeadLineReq.getNoun();
34+
return headlineGeneratorService.generateHeadLine(option, verb, noun);
35+
}
36+
37+
38+
private User validateUser(UserPrincipal userPrincipal) {
39+
Optional<User> userOptional = userService.findById(userPrincipal.getId());
40+
DefaultAssert.isOptionalPresent(userOptional);
41+
return userOptional.get();
42+
}
43+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.movelog.domain.news.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
@Getter
13+
public class NewsHeadLineReq {
14+
@Schema( type = "String", example ="첫 도전, 오랜만에 다시, 꾸준히 이어온 기록, 끊어낸 습관 중 택 1", description="뉴스 헤드라인 고정 옵션입니다.")
15+
private String option;
16+
17+
@Schema( type = "String", example ="했어요, 먹었어요, 갔어요 중 택 1", description="사용자가 선택한 동사 정보입니다.")
18+
private String verb;
19+
20+
@Schema( type = "String", example ="클라이밍", description="사용자가 선택한 명사 정보입니다.")
21+
private String noun;
22+
23+
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.movelog.domain.news.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
@Getter
13+
public class HeadLineRes {
14+
@Schema( type = "String", example ="5년 만의 첫 도전, 무엇이 그를 움직이게 했나?", description="뉴스 헤드라인 추천 내용입니다.")
15+
private String headLine;
16+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.movelog.domain.news.presentation;
2+
3+
import com.movelog.domain.news.application.NewsService;
4+
import com.movelog.domain.news.dto.request.NewsHeadLineReq;
5+
import com.movelog.domain.news.dto.response.HeadLineRes;
6+
import com.movelog.global.config.security.token.CurrentUser;
7+
import com.movelog.global.config.security.token.UserPrincipal;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.web.bind.annotation.PostMapping;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RestController;
19+
20+
import java.util.List;
21+
22+
@Slf4j
23+
@RestController
24+
@RequiredArgsConstructor
25+
@RequestMapping("/api/v1/news")
26+
@Tag(name = "News", description = "뉴스 관련 API입니다.")
27+
public class NewsController {
28+
29+
private final NewsService newsService;
30+
31+
@Operation(summary = "뉴스 헤드라인 생성 API", description = "뉴스 헤드라인을 생성하는 API입니다.")
32+
@ApiResponses(value = {
33+
@ApiResponse(responseCode = "200", description = "뉴스 헤드라인 생성 성공"),
34+
@ApiResponse(responseCode = "400", description = "뉴스 헤드라인 생성 실패")
35+
})
36+
@PostMapping("/headline")
37+
public List<HeadLineRes> createHeadLine(
38+
@Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal,
39+
@Parameter(description = "뉴스 헤드라인 생성 요청", required = true) @RequestBody NewsHeadLineReq newsHeadLineReq
40+
) {
41+
return newsService.createHeadLine(userPrincipal, newsHeadLineReq);
42+
}
43+
44+
45+
46+
}
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package com.movelog.domain.user.application;
22

3+
import com.movelog.domain.user.domain.User;
34
import com.movelog.domain.user.domain.repository.UserRepository;
4-
import com.movelog.global.payload.Message;
5-
import jakarta.persistence.EntityNotFoundException;
65
import lombok.RequiredArgsConstructor;
76

87
import org.springframework.stereotype.Service;
98
import org.springframework.transaction.annotation.Transactional;
10-
import org.springframework.web.multipart.MultipartFile;
9+
10+
import java.util.Optional;
1111

1212
@RequiredArgsConstructor
1313
@Service
@@ -16,4 +16,7 @@ public class UserService {
1616

1717
private final UserRepository userRepository;
1818

19+
public Optional<User> findById(Long id) {
20+
return userRepository.findById(id);
21+
}
1922
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.movelog.domain.user.exception;
2+
3+
4+
import com.movelog.global.exception.DuplicateException;
5+
6+
public class UserDuplicateException extends DuplicateException {
7+
8+
public UserDuplicateException() {
9+
super("U002", "이미 존재하는 사용자입니다.");
10+
}
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.movelog.domain.user.exception;
2+
3+
import com.movelog.global.exception.NotFoundException;
4+
5+
public class UserNotFoundException extends NotFoundException {
6+
7+
public UserNotFoundException() {
8+
super("U001", "사용자를 찾을 수 없습니다.");
9+
}
10+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.movelog.global.exception;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class DuplicateException extends RuntimeException {
7+
private final int statusCode;
8+
private final String code;
9+
private final String message;
10+
11+
public DuplicateException(String code, String message) {
12+
super(message);
13+
this.statusCode = 409; // Conflict status code
14+
this.code = code;
15+
this.message = message;
16+
}
17+
}

0 commit comments

Comments
 (0)