Repository navigation
[feat] AI 댓글 수동 할당 및 자동 감지 토글 API 추가 #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,17 +3,22 @@ | |
| import com.daramg.server.aicomment.domain.AiCommentJob; | ||
| import com.daramg.server.aicomment.domain.AiCommentJobStatus; | ||
| import com.daramg.server.aicomment.domain.AiCommentJobTriggerType; | ||
| import com.daramg.server.aicomment.domain.AiCommentSettings; | ||
| import com.daramg.server.aicomment.infrastructure.GeminiClient; | ||
| import com.daramg.server.aicomment.repository.AiCommentJobRepository; | ||
| import com.daramg.server.aicomment.repository.AiCommentSettingsRepository; | ||
| import com.daramg.server.comment.domain.Comment; | ||
| import com.daramg.server.comment.repository.CommentRepository; | ||
| import com.daramg.server.composer.domain.Composer; | ||
| import com.daramg.server.composer.domain.ComposerPersona; | ||
| import com.daramg.server.composer.repository.ComposerPersonaRepository; | ||
| import com.daramg.server.composer.repository.ComposerRepository; | ||
| import com.daramg.server.common.exception.NotFoundException; | ||
| import com.daramg.server.post.domain.Post; | ||
| import com.daramg.server.post.domain.PostStatus; | ||
| import com.daramg.server.post.domain.StoryPost; | ||
| import com.daramg.server.post.domain.CurationPost; | ||
| import com.daramg.server.post.repository.PostRepository; | ||
| import com.daramg.server.user.domain.User; | ||
| import com.daramg.server.user.repository.UserRepository; | ||
| import jakarta.annotation.PostConstruct; | ||
|
|
@@ -43,8 +48,11 @@ public class AiCommentService { | |
| private int replyDelayMinutes; | ||
|
|
||
| private final AiCommentJobRepository aiCommentJobRepository; | ||
| private final AiCommentSettingsRepository aiCommentSettingsRepository; | ||
| private final ComposerPersonaRepository composerPersonaRepository; | ||
| private final ComposerRepository composerRepository; | ||
| private final CommentRepository commentRepository; | ||
| private final PostRepository postRepository; | ||
| private final UserRepository userRepository; | ||
| private final GeminiClient geminiClient; | ||
|
|
||
|
|
@@ -66,12 +74,52 @@ private User getBotUser() { | |
| return botUser; | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public boolean isAutoDetectEnabled() { | ||
| return aiCommentSettingsRepository.findAll().stream() | ||
| .findFirst() | ||
| .map(AiCommentSettings::isAutoDetectEnabled) | ||
| .orElse(true); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void setAutoDetectEnabled(boolean enabled) { | ||
| AiCommentSettings settings = aiCommentSettingsRepository.findAll().stream() | ||
| .findFirst() | ||
| .orElseThrow(() -> new IllegalStateException("AI 댓글 설정을 찾을 수 없습니다.")); | ||
|
Comment on lines
+87
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
AiCommentSettings settings = aiCommentSettingsRepository.findFirstByOrderByIdAsc()
.orElseThrow(() -> new IllegalStateException("AI 댓글 설정을 찾을 수 없습니다.")); |
||
| settings.setAutoDetectEnabled(enabled); | ||
| log.info("AI 자동 감지 설정 변경 - enabled={}", enabled); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void scheduleManually(Long postId, Long composerId) { | ||
| Post post = postRepository.findById(postId) | ||
| .orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다.")); | ||
| Composer composer = composerRepository.findById(composerId) | ||
| .orElseThrow(() -> new NotFoundException("작곡가를 찾을 수 없습니다.")); | ||
|
|
||
| ComposerPersona persona = composerPersonaRepository.findByComposerId(composerId) | ||
| .orElseThrow(() -> new NotFoundException("해당 작곡가의 페르소나를 찾을 수 없습니다.")); | ||
| if (!persona.isActive()) { | ||
| throw new IllegalStateException("비활성화된 페르소나입니다."); | ||
| } | ||
|
|
||
| AiCommentJob job = AiCommentJob.of(post, composer, AiCommentJobTriggerType.ADMIN_ASSIGNED, null, Instant.now()); | ||
| aiCommentJobRepository.save(job); | ||
| log.info("AI 댓글 수동 할당 - postId={}, composerId={}", postId, composerId); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void scheduleForPost(Post post) { | ||
| if (post.getPostStatus() != PostStatus.PUBLISHED) { | ||
| return; | ||
| } | ||
|
|
||
| if (!isAutoDetectEnabled()) { | ||
| log.info("AI 자동 감지 비활성화 상태 - postId={} 스킵", post.getId()); | ||
| return; | ||
| } | ||
|
|
||
| List<Composer> composers = getComposersForPost(post); | ||
| log.info("AI 댓글 대상 작곡가 탐지 - postId={}, composers={}", post.getId(), | ||
| composers.stream().map(c -> c.getId() + "(" + c.getKoreanName() + ")").toList()); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,5 +2,6 @@ | |
|
|
||
| public enum AiCommentJobTriggerType { | ||
| POST_CREATED, | ||
| USER_REPLY | ||
| USER_REPLY, | ||
| ADMIN_ASSIGNED | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.daramg.server.aicomment.domain; | ||
|
|
||
| import com.daramg.server.common.domain.BaseEntity; | ||
| import jakarta.persistence.*; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Getter | ||
| @Table(name = "ai_comment_settings") | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class AiCommentSettings extends BaseEntity<AiCommentSettings> { | ||
|
|
||
| @Column(name = "auto_detect_enabled", nullable = false) | ||
| private boolean autoDetectEnabled = true; | ||
|
|
||
| public static AiCommentSettings defaultSettings() { | ||
| return new AiCommentSettings(); | ||
| } | ||
|
|
||
| public void setAutoDetectEnabled(boolean enabled) { | ||
| this.autoDetectEnabled = enabled; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.daramg.server.aicomment.presentation; | ||
|
|
||
| import com.daramg.server.aicomment.application.AiCommentService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/admin/ai-comments") | ||
| @RequiredArgsConstructor | ||
| public class AiCommentAdminController { | ||
|
|
||
| private final AiCommentService aiCommentService; | ||
|
|
||
| @PostMapping("/posts/{postId}/assign") | ||
| @ResponseStatus(HttpStatus.CREATED) | ||
| public void assignComposer( | ||
| @PathVariable Long postId, | ||
| @RequestParam Long composerId | ||
| ) { | ||
| aiCommentService.scheduleManually(postId, composerId); | ||
| } | ||
|
|
||
| @GetMapping("/settings") | ||
| public AutoDetectSettingsResponse getSettings() { | ||
| return new AutoDetectSettingsResponse(aiCommentService.isAutoDetectEnabled()); | ||
| } | ||
|
|
||
| @PutMapping("/settings/auto-detect") | ||
| @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| public void setAutoDetect(@RequestParam boolean enabled) { | ||
| aiCommentService.setAutoDetectEnabled(enabled); | ||
| } | ||
|
|
||
| public record AutoDetectSettingsResponse(boolean autoDetectEnabled) {} | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,7 @@ | ||||||||||||
| package com.daramg.server.aicomment.repository; | ||||||||||||
|
|
||||||||||||
| import com.daramg.server.aicomment.domain.AiCommentSettings; | ||||||||||||
| import org.springframework.data.jpa.repository.JpaRepository; | ||||||||||||
|
|
||||||||||||
| public interface AiCommentSettingsRepository extends JpaRepository<AiCommentSettings, Long> { | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+6
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -77,6 +77,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | |||||
| .requestMatchers(HttpMethod.POST, "/banners/images").hasRole("ADMIN") | ||||||
| .requestMatchers(HttpMethod.PUT, "/banners/**").hasRole("ADMIN") | ||||||
| .requestMatchers(HttpMethod.DELETE, "/banners/**").hasRole("ADMIN") | ||||||
| .requestMatchers("/admin/**").hasRole("ADMIN") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| /** | ||||||
| * 위에서 등록되지 않은 모든 경로는 인증 필요 | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| CREATE TABLE ai_comment_settings ( | ||
| id BIGINT AUTO_INCREMENT PRIMARY KEY, | ||
| auto_detect_enabled BOOLEAN NOT NULL DEFAULT TRUE, | ||
| created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), | ||
| updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) | ||
| ); | ||
|
|
||
| INSERT INTO ai_comment_settings (auto_detect_enabled) VALUES (TRUE); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| package com.daramg.server.aicomment.application; | ||
|
|
||
| import com.daramg.server.aicomment.domain.AiCommentJob; | ||
| import com.daramg.server.aicomment.domain.AiCommentJobTriggerType; | ||
| import com.daramg.server.aicomment.domain.AiCommentSettings; | ||
| import com.daramg.server.aicomment.infrastructure.GeminiClient; | ||
| import com.daramg.server.aicomment.repository.AiCommentJobRepository; | ||
| import com.daramg.server.aicomment.repository.AiCommentSettingsRepository; | ||
| import com.daramg.server.common.exception.NotFoundException; | ||
| import com.daramg.server.composer.domain.Composer; | ||
| import com.daramg.server.composer.domain.ComposerPersona; | ||
| import com.daramg.server.composer.domain.Gender; | ||
| import com.daramg.server.composer.repository.ComposerPersonaRepository; | ||
| import com.daramg.server.composer.repository.ComposerRepository; | ||
| import com.daramg.server.post.domain.FreePost; | ||
| import com.daramg.server.post.domain.Post; | ||
| import com.daramg.server.post.domain.PostStatus; | ||
| import com.daramg.server.post.domain.vo.PostCreateVo; | ||
| import com.daramg.server.post.repository.PostRepository; | ||
| import com.daramg.server.testsupport.support.ServiceTestSupport; | ||
| import com.daramg.server.user.domain.User; | ||
| import com.daramg.server.user.repository.UserRepository; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Nested; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.test.context.bean.override.mockito.MockitoBean; | ||
| import org.springframework.test.util.ReflectionTestUtils; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| public class AiCommentAdminServiceTest extends ServiceTestSupport { | ||
|
|
||
| @Autowired | ||
| private AiCommentService aiCommentService; | ||
|
|
||
| @Autowired | ||
| private AiCommentJobRepository aiCommentJobRepository; | ||
|
|
||
| @Autowired | ||
| private AiCommentSettingsRepository aiCommentSettingsRepository; | ||
|
|
||
|
|
||
| @Autowired | ||
| private ComposerRepository composerRepository; | ||
|
|
||
| @Autowired | ||
| private ComposerPersonaRepository composerPersonaRepository; | ||
|
|
||
| @Autowired | ||
| private PostRepository postRepository; | ||
|
|
||
| @Autowired | ||
| private UserRepository userRepository; | ||
|
|
||
| @MockitoBean | ||
| private GeminiClient geminiClient; | ||
|
|
||
| private User user; | ||
| private User botUser; | ||
| private Composer composer; | ||
| private Post post; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| user = new User("user@test.com", "password", "테스터", LocalDate.now(), null, "테스터닉", null, null); | ||
| userRepository.save(user); | ||
|
|
||
| botUser = new User("ai-bot@classicaldaramz.com", "LOCKED", "AI", LocalDate.of(2000, 1, 1), null, "ai_bot", null, null); | ||
| userRepository.save(botUser); | ||
| ReflectionTestUtils.setField(aiCommentService, "botUser", botUser); | ||
|
|
||
| composer = Composer.builder() | ||
| .koreanName("베토벤").englishName("Beethoven").gender(Gender.MALE).build(); | ||
| composerRepository.save(composer); | ||
|
|
||
| post = postRepository.save(FreePost.from(new PostCreateVo.Free( | ||
| user, "제목", "내용", PostStatus.PUBLISHED, List.of(), null, List.of() | ||
| ))); | ||
|
|
||
| aiCommentSettingsRepository.save(AiCommentSettings.defaultSettings()); | ||
| } | ||
|
|
||
| private ComposerPersona savePersona(Composer c) { | ||
| ComposerPersona persona = ComposerPersona.builder() | ||
| .composer(c).identity("완벽주의자").mission("연습 독려").constraintsText("반말, 150자 이내").build(); | ||
| return composerPersonaRepository.save(persona); | ||
| } | ||
|
|
||
| private AiCommentSettings getSettings() { | ||
| return aiCommentSettingsRepository.findAll().get(0); | ||
| } | ||
|
|
||
| @Nested | ||
| @DisplayName("자동 감지 토글") | ||
| class AutoDetectToggleTest { | ||
|
|
||
| @Test | ||
| void 자동감지를_비활성화하면_false가_반환된다() { | ||
| // when | ||
| aiCommentService.setAutoDetectEnabled(false); | ||
|
|
||
| // then | ||
| assertThat(aiCommentService.isAutoDetectEnabled()).isFalse(); | ||
| } | ||
|
|
||
| @Test | ||
| void 자동감지를_다시_활성화하면_true가_반환된다() { | ||
| // given | ||
| aiCommentService.setAutoDetectEnabled(false); | ||
|
|
||
| // when | ||
| aiCommentService.setAutoDetectEnabled(true); | ||
|
|
||
| // then | ||
| assertThat(aiCommentService.isAutoDetectEnabled()).isTrue(); | ||
| } | ||
|
|
||
| @Test | ||
| void 자동감지_비활성화_시_게시물_발행해도_잡이_등록되지_않는다() { | ||
| // given | ||
| savePersona(composer); | ||
| Post mentionPost = postRepository.save(FreePost.from(new PostCreateVo.Free( | ||
| user, "베토벤 이야기", "베토벤 내용", PostStatus.PUBLISHED, List.of(), null, List.of() | ||
| ))); | ||
| aiCommentService.setAutoDetectEnabled(false); | ||
|
|
||
| // when | ||
| aiCommentService.scheduleForPost(mentionPost); | ||
|
|
||
| // then | ||
| assertThat(aiCommentJobRepository.findAll()).isEmpty(); | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| @DisplayName("수동 할당") | ||
| class ManualAssignTest { | ||
|
|
||
| @Test | ||
| void 수동_할당_시_ADMIN_ASSIGNED_잡이_즉시_등록된다() { | ||
| // given | ||
| savePersona(composer); | ||
|
|
||
| // when | ||
| aiCommentService.scheduleManually(post.getId(), composer.getId()); | ||
|
|
||
| // then | ||
| List<AiCommentJob> jobs = aiCommentJobRepository.findAll(); | ||
| assertThat(jobs).hasSize(1); | ||
| assertThat(jobs.get(0).getTriggerType()).isEqualTo(AiCommentJobTriggerType.ADMIN_ASSIGNED); | ||
| assertThat(jobs.get(0).getComposer().getId()).isEqualTo(composer.getId()); | ||
| } | ||
|
|
||
| @Test | ||
| void 자동감지_비활성화_상태에서도_수동_할당은_가능하다() { | ||
| // given | ||
| savePersona(composer); | ||
| aiCommentService.setAutoDetectEnabled(false); | ||
|
|
||
| // when | ||
| aiCommentService.scheduleManually(post.getId(), composer.getId()); | ||
|
|
||
| // then | ||
| assertThat(aiCommentJobRepository.findAll()).hasSize(1); | ||
| } | ||
|
|
||
| @Test | ||
| void 존재하지_않는_게시물에_수동_할당_시_예외가_발생한다() { | ||
| // when & then | ||
| assertThatThrownBy(() -> aiCommentService.scheduleManually(999L, composer.getId())) | ||
| .isInstanceOf(NotFoundException.class); | ||
| } | ||
|
|
||
| @Test | ||
| void 페르소나_없는_작곡가에_수동_할당_시_예외가_발생한다() { | ||
| // when & then | ||
| assertThatThrownBy(() -> aiCommentService.scheduleManually(post.getId(), composer.getId())) | ||
| .isInstanceOf(NotFoundException.class); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
findAll()을 사용하여 설정을 조회하는 대신,AiCommentSettingsRepository에 추가를 제안한findFirstByOrderByIdAsc()메서드를 사용하는 것이 좋습니다. 이 방법은 의도를 더 명확하게 드러내고, 여러 설정 행이 존재할 경우의 예기치 않은 동작을 방지하며, 더 효율적입니다.