Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public SecurityFilterChain filterChain(
.requestMatchers(HttpMethod.GET, "/api/v1/news/sitemap", "/api/v1/news/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/app/version").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/terms").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/chatbot/messages").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/members/me", "/api/v1/members/me/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/v1/members/*").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/members/find-email").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package checkmo.chatbot.internal.config;

import checkmo.chatbot.internal.config.properties.ChatbotProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
@RequiredArgsConstructor
public class ChatbotRestTemplateConfig {

private final ChatbotProperties chatbotProperties;

/**
* book 모듈의 기본 {@code restTemplate} 빈(Aladin 전용 타임아웃/XML 컨버터 설정)과 분리된
* 챗봇(Gemini) 전용 RestTemplate. 빈 이름을 파라미터/필드명과 일치시켜 이름 기준으로 주입되도록 한다.
*
* 주의: classpath에 jackson-dataformat-xml(Aladin 연동용)이 있어 RestTemplate 기본 컨버터 목록에
* XML 컨버터가 JSON 컨버터보다 먼저 등록된다. 호출부(GeminiApiService)에서 Content-Type을
* application/json으로 명시하지 않으면 요청 바디가 XML로 직렬화되어 버리니 반드시 명시할 것.
*/
@Bean
public RestTemplate chatbotRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(chatbotProperties.getTimeoutMs());
factory.setReadTimeout(chatbotProperties.getTimeoutMs());

return new RestTemplate(factory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package checkmo.chatbot.internal.config.properties;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "chatbot.gemini")
public class ChatbotProperties {
Comment on lines +11 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate ChatbotProperties =="
fd -a 'ChatbotProperties\.java' . || true

echo "== Inspect ChatbotProperties =="
file="$(fd 'ChatbotProperties\.java' . | head -n 1 || true)"
if [ -n "$file" ]; then
  wc -l "$file"
  sed -n '1,220p' "$file" | cat -n
fi

echo "== Search for validateConfigurationProperties / ValidationAutoConfiguration references =="
rg -n "validateConfigurationProperties|validation\.configurationProperties|`@ConfigurationProperties`|`@Validated`" src/main/java -S || true

echo "== Search for ChatbotProperties consumers =="
rg -n "ChatbotProperties|chatbotProperties|getApiKey\(|getBaseUrl\(|getApiVersion\()" src/main/java -S || true

echo "== Spring Boot version declarations =="
fd -a 'pom.xml|build.gradle|build.gradle.kts' . | sed 's#^\./##' | while read -r f; do
  echo "--- $f"
  rg -n "spring-boot|version>" "$f" -S || true
done

Repository: checkmo2025/BE

Length of output: 5862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ChatbotProperties consumers =="
rg -n "ChatbotProperties|chatbotProperties|getApiKey\\(|getBaseUrl\\(|getApiVersion\\(|GeminiApiService" src/main/java -S || true

echo "== Build files =="
fd -a '^(build.gradle\.kts|build.gradle|gradle.properties|pom.xml)$' . | sed 's#^\./##' | sort | while read -r f; do
  echo "--- $f"
  sed -n '1,140p' "$f" | cat -n
done

echo "== Check Boot property for validation of configuration properties =="
rg -n "validateConfigurationProperties|ConfigurationProperties:|enable.*validation|validation\\.configurationProperties" . -S --glob 'src/main/java' --glob 'src/main/resources' --glob '*.gradle' --glob '*.properties' --glob '*.yml' --glob '*.yaml' || true

Repository: checkmo2025/BE

Length of output: 8390


🌐 Web query:

Spring Boot 3.5 @ConfigurationPropertiesBean Validation requires@validated documentation

💡 Result:

In Spring Boot 3.5, using Bean Validation (JSR 380) annotations (such as @NotBlank, @NotNull, @Size) on a @ConfigurationProperties bean requires the class to be annotated with @Validated to trigger the validation process [1]. Key requirements for validating @ConfigurationProperties beans: 1. Dependency: You must have a JSR-303/JSR-380 implementation, such as Hibernate Validator, on your classpath. This is typically provided by including the spring-boot-starter-validation dependency [2][1][3]. 2. Enabling Validation: Adding @Validated to your @ConfigurationProperties class is mandatory [1]. Without this annotation, the constraint annotations placed on the fields will be ignored by the binding process [1]. 3. Nested Objects: To cascade validation to nested objects, you must annotate the nested field with @Valid [1][3]. Note that in some versions of Spring Boot, validation of nested objects might occur even without @Valid due to specific implementation details in ConfigurationPropertiesBinder, though using @Valid is considered the standard practice to align with the Bean Validation specification [4][3]. This approach allows your application to fail fast at startup if configuration properties do not meet the defined constraints, preventing runtime errors later [1][3]. Ensure that your @ConfigurationProperties bean is correctly registered as a bean, for example, by using @EnableConfigurationProperties or component scanning [1][5].

Citations:


Add @Validated to ChatbotProperties so constraints run during binding.

Spring Boot validates a @ConfigurationProperties class only when it is annotated with @Validated, and spring-boot-starter-validation provides the implementation. Without it, @NotBlank on apiKey, baseUrl, apiVersion, timeoutMs, and nested @Valid fields are skipped, so invalid Gemini config can bind successfully and fail later in GeminiApiService.

🛡️ Proposed fix
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.Positive;
 import lombok.Getter;
 import lombok.Setter;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;

 `@Getter`
 `@Setter`
 `@Component`
+@Validated
 `@ConfigurationProperties`(prefix = "chatbot.gemini")
 public class ChatbotProperties {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "chatbot.gemini")
public class ChatbotProperties {
`@Getter`
`@Setter`
`@Component`
`@Validated`
`@ConfigurationProperties`(prefix = "chatbot.gemini")
public class ChatbotProperties {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/checkmo/chatbot/internal/config/properties/ChatbotProperties.java`
around lines 11 - 15, Update the ChatbotProperties class by adding Spring’s
`@Validated` annotation alongside `@ConfigurationProperties` so validation
constraints on apiKey, baseUrl, apiVersion, timeoutMs, and nested `@Valid` fields
execute during configuration binding.


@NotBlank(message = "Gemini API 키는 필수입니다")
private String apiKey;

@NotBlank(message = "Gemini API 기본 URL은 필수입니다")
private String baseUrl = "https://generativelanguage.googleapis.com";

@NotBlank(message = "Gemini API 버전은 필수입니다")
private String apiVersion = "v1beta";

@Positive(message = "타임아웃은 양수여야 합니다")
private int timeoutMs = 15000;

@Valid
private Model defaultModel = new Model();

@Valid
private Model escalationModel = new Model();

@Valid
private Handoff handoff = new Handoff();

@Getter
@Setter
public static class Model {
@NotBlank(message = "모델명은 필수입니다")
private String name;
}

@Getter
@Setter
public static class Handoff {
@NotBlank(message = "고객센터 URL은 필수입니다")
private String supportUrl;

@NotBlank(message = "문의 폼 URL은 필수입니다")
private String inquiryFormUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package checkmo.chatbot.internal.converter;

import checkmo.chatbot.internal.service.ChatReply;
import checkmo.chatbot.web.dto.ChatbotResponseDTO;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ChatbotConverter {

public static ChatbotResponseDTO.Reply toReplyResponse(ChatReply chatReply) {
return ChatbotResponseDTO.Reply.builder()
.sessionToken(chatReply.sessionToken())
.replyText(chatReply.replyText())
.escalated(chatReply.escalated())
.modelUsed(chatReply.modelUsed())
.handoffSuggested(chatReply.handoffSuggested())
.supportUrl(chatReply.supportUrl())
.inquiryFormUrl(chatReply.inquiryFormUrl())
.build();
}
}
79 changes: 79 additions & 0 deletions src/main/java/checkmo/chatbot/internal/entity/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package checkmo.chatbot.internal.entity;

import checkmo.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatMessage extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long chatSessionId;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ChatRole role;

// 마스킹된 텍스트만 저장한다. 원문(PII 포함 가능)은 어떤 컬럼에도 저장하지 않는다.
// @Lob만 쓰면 기본 length(255) 때문에 Hibernate가 TINYTEXT를 기대해 TEXT 컬럼과 스키마 검증이 어긋난다.
@Column(nullable = false, columnDefinition = "TEXT")
private String maskedContent;

@Column(length = 100)
private String modelUsed; // ASSISTANT 메시지에만 사용

@Column(nullable = false)
private boolean escalated;

@Column(nullable = false)
private boolean botUncertain;

@Column(nullable = false)
private boolean userNegativeReaction;

private ChatMessage(
Long chatSessionId,
ChatRole role,
String maskedContent,
String modelUsed,
boolean escalated,
boolean botUncertain,
boolean userNegativeReaction
) {
this.chatSessionId = chatSessionId;
this.role = role;
this.maskedContent = maskedContent;
this.modelUsed = modelUsed;
this.escalated = escalated;
this.botUncertain = botUncertain;
this.userNegativeReaction = userNegativeReaction;
}

public static ChatMessage userMessage(Long chatSessionId, String maskedContent, boolean userNegativeReaction) {
return new ChatMessage(chatSessionId, ChatRole.USER, maskedContent, null, false, false, userNegativeReaction);
}

public static ChatMessage assistantMessage(
Long chatSessionId,
String maskedContent,
String modelUsed,
boolean escalated,
boolean botUncertain
) {
return new ChatMessage(chatSessionId, ChatRole.ASSISTANT, maskedContent, modelUsed, escalated, botUncertain, false);
}
}
6 changes: 6 additions & 0 deletions src/main/java/checkmo/chatbot/internal/entity/ChatRole.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package checkmo.chatbot.internal.entity;

public enum ChatRole {
USER,
ASSISTANT
}
53 changes: 53 additions & 0 deletions src/main/java/checkmo/chatbot/internal/entity/ChatSession.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package checkmo.chatbot.internal.entity;

import checkmo.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatSession extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column
private Long memberId; // 비로그인 사용자는 null

@Column(nullable = false, unique = true, length = 64)
private String sessionToken;

@Column(nullable = false)
private boolean unresolved;

@Column(nullable = false)
private LocalDateTime lastActivityAt;

private ChatSession(Long memberId, String sessionToken) {
this.memberId = memberId;
this.sessionToken = sessionToken;
this.unresolved = false;
this.lastActivityAt = LocalDateTime.now();
}

public static ChatSession start(Long memberId, String sessionToken) {
return new ChatSession(memberId, sessionToken);
}

public void recordActivity() {
this.lastActivityAt = LocalDateTime.now();
}

public void flagUnresolved() {
this.unresolved = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package checkmo.chatbot.internal.exception;

import checkmo.common.apiPayload.code.BaseErrorCode;
import checkmo.common.apiPayload.code.ErrorReasonDTO;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum ChatbotErrorStatus implements BaseErrorCode {

GEMINI_API_CALL_FAILED(HttpStatus.BAD_GATEWAY, "CHATBOT_502", "챗봇 응답 생성에 실패했습니다."),
GEMINI_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "CHATBOT_503", "챗봇 응답이 비어 있습니다."),
SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "CHATBOT_404", "존재하지 않는 챗봇 세션입니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

@Override
public ErrorReasonDTO getReason() {
return ErrorReasonDTO.builder()
.code(code)
.message(message)
.isSuccess(false)
.build();
}

@Override
public ErrorReasonDTO getReasonHttpStatus() {
return ErrorReasonDTO.builder()
.httpStatus(httpStatus)
.code(code)
.message(message)
.isSuccess(false)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package checkmo.chatbot.internal.exception;

import checkmo.common.apiPayload.exception.GeneralException;

public class ChatbotException extends GeneralException {

public ChatbotException(ChatbotErrorStatus status) {
super(status);
}

public ChatbotException(ChatbotErrorStatus status, Throwable cause) {
super(status, cause);
}
}
Loading