Skip to content

Commit ff0b928

Browse files
authored
Merge pull request #23 from fallaylist/feat/ai-recommendation-valley
feat: AI 계곡 추천 기능 구현
2 parents d5ae189 + 53963a6 commit ff0b928

34 files changed

Lines changed: 461 additions & 71 deletions

‎build.gradle.kts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ plugins {
33
id("org.springframework.boot") version "3.5.3"
44
id("io.spring.dependency-management") version "1.1.7"
55
}
6+
val springAiVersion by extra("1.0.1")
67

78
group = "fallguys"
89
version = "0.0.1-SNAPSHOT"
@@ -36,6 +37,7 @@ dependencies {
3637
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
3738
implementation("software.amazon.awssdk:bom:2.32.29")
3839
implementation("software.amazon.awssdk:s3:2.32.29")
40+
implementation("org.springframework.ai:spring-ai-starter-model-openai")
3941

4042
compileOnly("org.projectlombok:lombok")
4143
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
@@ -50,6 +52,11 @@ dependencies {
5052
testImplementation("org.springframework.security:spring-security-test")
5153
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
5254
}
55+
dependencyManagement {
56+
imports {
57+
mavenBom("org.springframework.ai:spring-ai-bom:$springAiVersion")
58+
}
59+
}
5360

5461
tasks.withType<Test> {
5562
useJUnitPlatform()

‎compose.prod.yaml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,22 @@ services:
1010
- redis
1111
env_file:
1212
- ./.env
13-
1413
environment:
1514
- SPRING_PROFILES_ACTIVE=prod
15+
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/fallaylist?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
16+
- SPRING_DATASOURCE_USERNAME=${MYSQL_USER}
17+
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
1618
restart: always
1719

1820
mysql:
1921
image: 'mysql:8.0'
2022
env_file:
2123
- ./.env
24+
environment:
25+
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
26+
- MYSQL_DATABASE=${MYSQL_DATABASE}
27+
- MYSQL_USER=${MYSQL_USER}
28+
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
2229
ports:
2330
- "3306:3306"
2431
volumes:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package fallguys.fallaylist.adapter.in.scheduler.recommendation;
2+
3+
import fallguys.fallaylist.application.recommendation.provided.ValleyRecommendationRegister;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.scheduling.annotation.Scheduled;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class RecommendationScheduler {
11+
private final ValleyRecommendationRegister recommendationRegister;
12+
13+
@Scheduled(cron = "0 0 2 * * ?")
14+
public void updateRecommendations() {
15+
recommendationRegister.update();
16+
}
17+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package fallguys.fallaylist.adapter.out.external.ai;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import fallguys.fallaylist.adapter.out.external.ai.dto.AiValleyRecommendationResponse;
6+
import fallguys.fallaylist.application.recommendation.dto.KeywordAiPromt;
7+
import fallguys.fallaylist.application.recommendation.dto.ValleyAiPromt;
8+
import fallguys.fallaylist.application.recommendation.required.AiRecommendationPort;
9+
import java.io.IOException;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.Collections;
12+
import java.util.List;
13+
import java.util.Map;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.ai.chat.client.ChatClient;
17+
import org.springframework.ai.chat.prompt.PromptTemplate;
18+
import org.springframework.beans.factory.annotation.Value;
19+
import org.springframework.core.ParameterizedTypeReference;
20+
import org.springframework.core.io.Resource;
21+
import org.springframework.stereotype.Component;
22+
import org.springframework.util.StreamUtils;
23+
24+
@Slf4j
25+
@Component
26+
@RequiredArgsConstructor
27+
public class AiRecommendationAdapter implements AiRecommendationPort {
28+
29+
@Value("classpath:/prompts/system-message.st")
30+
private Resource systemMessageResource;
31+
32+
@Value("classpath:/prompts/user-message.st")
33+
private Resource userMessageResource;
34+
35+
private final ChatClient chatClient;
36+
private final ObjectMapper objectMapper;
37+
38+
@Override
39+
public List<AiValleyRecommendationResponse> recommendValleys(List<ValleyAiPromt> valleys,
40+
List<KeywordAiPromt> keywords) {
41+
try {
42+
String systemContent = getContent(systemMessageResource);
43+
44+
String valleysJson = objectMapper.writeValueAsString(valleys);
45+
String keywordsJson = objectMapper.writeValueAsString(keywords);
46+
47+
PromptTemplate userPromptTemplate = new PromptTemplate(userMessageResource);
48+
String userContent = userPromptTemplate.render(
49+
Map.of("valleysJson", valleysJson, "keywordsJson", keywordsJson)
50+
);
51+
52+
return chatClient.prompt()
53+
.system(systemContent)
54+
.user(userContent)
55+
.call()
56+
.entity(new ParameterizedTypeReference<List<AiValleyRecommendationResponse>>() {
57+
});
58+
59+
} catch (JsonProcessingException e) {
60+
log.error("Failed to serialize request data to JSON", e);
61+
return Collections.emptyList();
62+
} catch (IOException e) {
63+
log.error("Failed to read prompt resource file", e);
64+
throw new RuntimeException("Failed to read prompt files", e);
65+
}
66+
}
67+
68+
private String getContent(Resource resource) throws IOException {
69+
return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
70+
}
71+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package fallguys.fallaylist.adapter.out.external.ai.dto;
2+
3+
public record AiValleyRecommendationResponse(Long valleyId, Long keywordId, String title, String reason) {
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package fallguys.fallaylist.adapter.out.external.config;
2+
3+
import org.springframework.ai.chat.client.ChatClient;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
@Configuration
8+
public class ChatClientConfig {
9+
@Bean
10+
public ChatClient chatClient(ChatClient.Builder chatClientBuilder) {
11+
return chatClientBuilder.build();
12+
}
13+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package fallguys.fallaylist.adapter.out.persistence.recommendation;
2+
3+
import fallguys.fallaylist.application.recommendation.required.ValleyRecommendationRepositoryCustom;
4+
import fallguys.fallaylist.domain.recommendation.ValleyRecommendation;
5+
import java.sql.PreparedStatement;
6+
import java.sql.Timestamp;
7+
import java.util.List;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.jdbc.core.JdbcTemplate;
11+
import org.springframework.stereotype.Repository;
12+
13+
@Slf4j
14+
@Repository
15+
@RequiredArgsConstructor
16+
public class ValleyRecommendationRepositoryImpl implements ValleyRecommendationRepositoryCustom {
17+
private final JdbcTemplate jdbcTemplate;
18+
19+
@Override
20+
public void bulkInsert(List<ValleyRecommendation> recommendations) {
21+
String sql = "INSERT INTO valley_recommendation " +
22+
"(valley_id, keyword_id, title, reason, created_at, is_active) " +
23+
"VALUES (?, ?, ?, ?, ?, ?)";
24+
25+
jdbcTemplate.batchUpdate(sql, recommendations, recommendations.size(),
26+
(PreparedStatement ps, ValleyRecommendation recommendation) -> {
27+
ps.setLong(1, recommendation.getValley().getId());
28+
ps.setLong(2, recommendation.getKeyword().getId());
29+
ps.setString(3, recommendation.getTitle());
30+
ps.setString(4, recommendation.getReason());
31+
ps.setTimestamp(5, Timestamp.valueOf(recommendation.getCreatedAt()));
32+
ps.setBoolean(6, recommendation.isActive());
33+
});
34+
}
35+
}

‎src/main/java/fallguys/fallaylist/adapter/security/filter/JwtAuthenticationFilter.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import jakarta.servlet.http.HttpServletRequest;
1010
import jakarta.servlet.http.HttpServletResponse;
1111
import java.io.IOException;
12+
import java.util.List;
1213
import lombok.RequiredArgsConstructor;
1314
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1415
import org.springframework.security.core.Authentication;
@@ -21,9 +22,15 @@
2122
public class JwtAuthenticationFilter extends OncePerRequestFilter {
2223
private static final String AUTHORIZATION_HEADER = "Authorization";
2324
private static final String BEARER_PREFIX = "Bearer ";
25+
private static final List<String> EXCLUDED_PATHS = List.of("/api/token/reissue");
2426

2527
private final TokenParser tokenParser;
2628

29+
@Override
30+
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
31+
return EXCLUDED_PATHS.stream().anyMatch(request.getRequestURI()::startsWith);
32+
}
33+
2734
@Override
2835
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
2936
throws ServletException, IOException {

‎src/main/java/fallguys/fallaylist/adapter/security/oauth2/CustomOAuth2SuccessHandler.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
6161
redirectUrl = "http://localhost:3000/login?success=true";
6262
}
6363
}
64+
log.info("referer = {}", referer);
65+
log.info("redirectUrl = {}", redirectUrl);
6466

6567
response.sendRedirect(redirectUrl);
6668
log.info("소셜로그인 성공 user ID: {}", memberAuthInfo.id());

‎src/main/java/fallguys/fallaylist/adapter/webapi/api/member/dto/MemberInfoResponse.java‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,19 @@ public record MemberInfoResponse(String nickname, String profileImageUrl) {
66
public static MemberInfoResponse of(Member member, String cloudfrontUrl) {
77
return new MemberInfoResponse(
88
member.getNickname(),
9-
member.getProfileImageUrl() != null ? cloudfrontUrl + member.getProfileImageUrl() : null
9+
buildProfileImageUrl(member.getProfileImageUrl(), cloudfrontUrl)
1010
);
1111
}
12+
13+
private static String buildProfileImageUrl(String originalUrl, String cloudfrontUrl) {
14+
if (originalUrl == null) {
15+
return null;
16+
}
17+
18+
if (originalUrl.contains(".kakaocdn.net")) {
19+
return originalUrl;
20+
}
21+
22+
return cloudfrontUrl + originalUrl;
23+
}
1224
}

0 commit comments

Comments
 (0)