diff --git a/build.gradle b/build.gradle index 2e46b99..6dcca09 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,13 @@ configurations { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } + maven { + name = 'Central Portal Snapshots' + url = 'https://central.sonatype.com/repository/maven-snapshots/' + } + } dependencies { @@ -64,6 +71,9 @@ dependencies { // cache implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine:3.2.1' + + implementation platform("org.springframework.ai:spring-ai-bom:1.0.0-SNAPSHOT") + implementation 'org.springframework.ai:spring-ai-openai' } // Q파일 생성 위치 diff --git a/src/main/java/com/jiwon/mylog/domain/gpt/FortuneResponse.java b/src/main/java/com/jiwon/mylog/domain/gpt/FortuneResponse.java new file mode 100644 index 0000000..46b6324 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/gpt/FortuneResponse.java @@ -0,0 +1,13 @@ +package com.jiwon.mylog.domain.gpt; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Builder +@Getter +public class FortuneResponse { + private final String content; + private final LocalDateTime createdAt; +} diff --git a/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiController.java b/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiController.java new file mode 100644 index 0000000..0afacc9 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiController.java @@ -0,0 +1,22 @@ +package com.jiwon.mylog.domain.gpt; + +import com.jiwon.mylog.global.security.auth.annotation.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/openai") +@RestController +public class OpenAiController { + + private final OpenAiService openAiService; + + @GetMapping("/fortune") + public ResponseEntity getDailyFortune(@LoginUser Long userId) { + FortuneResponse response = openAiService.getDailyFortune(userId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiService.java b/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiService.java new file mode 100644 index 0000000..1ec52a5 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/gpt/OpenAiService.java @@ -0,0 +1,88 @@ +package com.jiwon.mylog.domain.gpt; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class OpenAiService { + + private final OpenAiChatModel chatModel; + private final OpenAiChatOptions openAiChatOptions; + + @Cacheable(value = "dailyFortune", key = "#userId + '_' + T(java.time.LocalDate).now()") + public FortuneResponse getDailyFortune(Long userId) { + try { + ChatResponse response = callOpenAiApi(userId); + String content = extractContent(response); + return FortuneResponse.builder() + .content(content) + .createdAt(LocalDateTime.now()) + .build(); + } catch (Exception e) { + return getDefaultFortune(); + } + } + + @CacheEvict(value = "dailyFortune", allEntries = true) + public void evictAllFortunes() { + log.info("All dailyFortune Cache deleted"); + } + + private ChatResponse callOpenAiApi(Long userId) { + SystemMessage systemMessage = new SystemMessage(""" + 넌 오늘의 운세를 나폴리탄 괴담 형식으로 알려주는 예언자야. + 기묘한 단어를 조금 섞어서 무서운 괴담 느낌 가득하게 운세를 말해줘. + 추가로 운세에는 아래 내용을 짧게 포함해야 해. + - 행운의 색, 행운의 물건, 나폴리탄 형식 괴담 주의사항 하나 + """); + String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")); + UserMessage userMessage = new UserMessage( + String.format("사용자 %d, %s 기준으로 오늘의 외계인 운세 알려줘.", userId, date)); + + List messages = Arrays.asList(systemMessage, userMessage); + + Prompt prompt = Prompt.builder() + .chatOptions(openAiChatOptions) + .messages(messages) + .build(); + + return chatModel.call(prompt); + } + + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null) { + throw new IllegalArgumentException("GPT 응답 오류"); + } + + String content = response.getResult().getOutput().getText(); + if (content == null || content.trim().isBlank()) { + throw new IllegalArgumentException("GPT 응답 오류"); + } + return content; + } + + private FortuneResponse getDefaultFortune() { + return FortuneResponse.builder() + .content("외계 통신 오 류! 외 계인을 구 해줘...") + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/jiwon/mylog/global/common/config/CacheConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/CacheConfig.java index f7abddf..7d54396 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/CacheConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/CacheConfig.java @@ -13,12 +13,18 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - Caffeine cache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofMinutes(10)) - .maximumSize(1_000); - CaffeineCacheManager cacheManager = new CaffeineCacheManager(); - cacheManager.setCaffeine(cache); + + cacheManager.setCaffeine(Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(Duration.ofHours(1))); + + cacheManager.registerCustomCache("dailyFortune", + Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(Duration.ofHours(1)) + .build()); + return cacheManager; } } diff --git a/src/main/java/com/jiwon/mylog/global/common/config/OpenAiConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/OpenAiConfig.java new file mode 100644 index 0000000..dad3fec --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/common/config/OpenAiConfig.java @@ -0,0 +1,41 @@ +package com.jiwon.mylog.global.common.config; + +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAiConfig { + + @Value("${spring.ai.openai.api-key}") + private String apiKey; + + @Value("${spring.ai.openai.chat.model}") + private String model; + + @Bean + public OpenAiApi openAiApi() { + return OpenAiApi.builder() + .apiKey(apiKey) + .build(); + } + + @Bean + public OpenAiChatOptions openAiChatOptions() { + return OpenAiChatOptions.builder() + .model(model) + .temperature(0.7) + .maxTokens(800) + .build(); + } + + @Bean + public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi) { + return OpenAiChatModel.builder() + .openAiApi(openAiApi) + .build(); + } +} diff --git a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java index 80aceff..dffd86d 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java @@ -87,7 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService) // 단순 조회 (권한X) .requestMatchers(HttpMethod.GET, "/api/users/**", "/api/posts/**", "/api/categories/**", "/api/images/**", "/api/points/**", "/api/items/**", "/api/sse/**", "/api/likes/**").permitAll() // 블로그 사용자 - .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/notifications/**", "/api/likes/**", "/api/readme/**").authenticated() + .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/notifications/**", "/api/likes/**", "/api/readme/**", "/api/openai/**").authenticated() // 관리자 전용 .requestMatchers("/api/admin/**").hasRole("ADMIN"));