diff --git a/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java b/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java index 9c6e2dd..06c3172 100644 --- a/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java +++ b/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java @@ -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 댓글 설정을 찾을 수 없습니다.")); + 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 composers = getComposersForPost(post); log.info("AI 댓글 대상 작곡가 탐지 - postId={}, composers={}", post.getId(), composers.stream().map(c -> c.getId() + "(" + c.getKoreanName() + ")").toList()); diff --git a/src/main/java/com/daramg/server/aicomment/domain/AiCommentJobTriggerType.java b/src/main/java/com/daramg/server/aicomment/domain/AiCommentJobTriggerType.java index 1e854ab..d9fc439 100644 --- a/src/main/java/com/daramg/server/aicomment/domain/AiCommentJobTriggerType.java +++ b/src/main/java/com/daramg/server/aicomment/domain/AiCommentJobTriggerType.java @@ -2,5 +2,6 @@ public enum AiCommentJobTriggerType { POST_CREATED, - USER_REPLY + USER_REPLY, + ADMIN_ASSIGNED } diff --git a/src/main/java/com/daramg/server/aicomment/domain/AiCommentSettings.java b/src/main/java/com/daramg/server/aicomment/domain/AiCommentSettings.java new file mode 100644 index 0000000..7ce43f8 --- /dev/null +++ b/src/main/java/com/daramg/server/aicomment/domain/AiCommentSettings.java @@ -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 { + + @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; + } +} diff --git a/src/main/java/com/daramg/server/aicomment/presentation/AiCommentAdminController.java b/src/main/java/com/daramg/server/aicomment/presentation/AiCommentAdminController.java new file mode 100644 index 0000000..02258f4 --- /dev/null +++ b/src/main/java/com/daramg/server/aicomment/presentation/AiCommentAdminController.java @@ -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) {} +} diff --git a/src/main/java/com/daramg/server/aicomment/repository/AiCommentSettingsRepository.java b/src/main/java/com/daramg/server/aicomment/repository/AiCommentSettingsRepository.java new file mode 100644 index 0000000..0df6d52 --- /dev/null +++ b/src/main/java/com/daramg/server/aicomment/repository/AiCommentSettingsRepository.java @@ -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 { +} diff --git a/src/main/java/com/daramg/server/auth/config/SecurityConfig.java b/src/main/java/com/daramg/server/auth/config/SecurityConfig.java index 73b0d96..bd9e824 100644 --- a/src/main/java/com/daramg/server/auth/config/SecurityConfig.java +++ b/src/main/java/com/daramg/server/auth/config/SecurityConfig.java @@ -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") /** * 위에서 등록되지 않은 모든 경로는 인증 필요 diff --git a/src/main/resources/db/migration/V12__add_ai_comment_settings.sql b/src/main/resources/db/migration/V12__add_ai_comment_settings.sql new file mode 100644 index 0000000..20c79d7 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_ai_comment_settings.sql @@ -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); diff --git a/src/test/java/com/daramg/server/aicomment/application/AiCommentAdminServiceTest.java b/src/test/java/com/daramg/server/aicomment/application/AiCommentAdminServiceTest.java new file mode 100644 index 0000000..a73920f --- /dev/null +++ b/src/test/java/com/daramg/server/aicomment/application/AiCommentAdminServiceTest.java @@ -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 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); + } + } +} diff --git a/src/test/java/com/daramg/server/aicomment/presentation/AiCommentAdminControllerTest.java b/src/test/java/com/daramg/server/aicomment/presentation/AiCommentAdminControllerTest.java new file mode 100644 index 0000000..2c62fe6 --- /dev/null +++ b/src/test/java/com/daramg/server/aicomment/presentation/AiCommentAdminControllerTest.java @@ -0,0 +1,114 @@ +package com.daramg.server.aicomment.presentation; + +import com.daramg.server.aicomment.application.AiCommentService; +import com.daramg.server.testsupport.support.ControllerTestSupport; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AiCommentAdminController.class) +public class AiCommentAdminControllerTest extends ControllerTestSupport { + + @MockitoBean + private AiCommentService aiCommentService; + + @Test + void 작곡가를_게시물에_수동_할당한다() throws Exception { + // given + Cookie cookie = new Cookie(COOKIE_NAME, "access_token"); + doNothing().when(aiCommentService).scheduleManually(1L, 10L); + + // when + ResultActions result = mockMvc.perform(post("/admin/ai-comments/posts/{postId}/assign", 1L) + .queryParam("composerId", "10") + .cookie(cookie)); + + // then + result.andExpect(status().isCreated()) + .andDo(restDocsHandler.document( + resource(ResourceSnippetParameters.builder() + .tag("AI Comment Admin API") + .summary("작곡가 수동 할당") + .description("특정 게시물에 작곡가를 직접 지정하여 AI 댓글 잡을 즉시 등록합니다. (ADMIN 전용)") + .pathParameters( + parameterWithName("postId").description("댓글을 달 게시물 ID") + ) + .queryParameters( + parameterWithName("composerId").description("댓글을 달 작곡가 ID") + ) + .build() + ), + requestCookies(cookieWithName(COOKIE_NAME).description("ADMIN 유저의 토큰")) + )); + } + + @Test + void 자동감지_설정을_조회한다() throws Exception { + // given + Cookie cookie = new Cookie(COOKIE_NAME, "access_token"); + when(aiCommentService.isAutoDetectEnabled()).thenReturn(true); + + // when + ResultActions result = mockMvc.perform(get("/admin/ai-comments/settings") + .cookie(cookie)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.autoDetectEnabled").value(true)) + .andDo(restDocsHandler.document( + resource(ResourceSnippetParameters.builder() + .tag("AI Comment Admin API") + .summary("자동 감지 설정 조회") + .description("AI 댓글 자동 감지 활성화 여부를 조회합니다. (ADMIN 전용)") + .responseFields( + fieldWithPath("autoDetectEnabled").type(BOOLEAN).description("자동 감지 활성화 여부") + ) + .build() + ), + requestCookies(cookieWithName(COOKIE_NAME).description("ADMIN 유저의 토큰")) + )); + } + + @Test + void 자동감지_설정을_변경한다() throws Exception { + // given + Cookie cookie = new Cookie(COOKIE_NAME, "access_token"); + doNothing().when(aiCommentService).setAutoDetectEnabled(false); + + // when + ResultActions result = mockMvc.perform(put("/admin/ai-comments/settings/auto-detect") + .queryParam("enabled", "false") + .cookie(cookie)); + + // then + result.andExpect(status().isNoContent()) + .andDo(restDocsHandler.document( + resource(ResourceSnippetParameters.builder() + .tag("AI Comment Admin API") + .summary("자동 감지 설정 변경") + .description("AI 댓글 자동 감지 기능을 켜거나 끕니다. (ADMIN 전용)") + .queryParameters( + parameterWithName("enabled").description("활성화 여부 (true/false)") + ) + .build() + ), + requestCookies(cookieWithName(COOKIE_NAME).description("ADMIN 유저의 토큰")) + )); + } +}