Skip to content
Merged
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 @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Comment on lines +79 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

findAll()을 사용하여 설정을 조회하는 대신, AiCommentSettingsRepository에 추가를 제안한 findFirstByOrderByIdAsc() 메서드를 사용하는 것이 좋습니다. 이 방법은 의도를 더 명확하게 드러내고, 여러 설정 행이 존재할 경우의 예기치 않은 동작을 방지하며, 더 효율적입니다.

        return aiCommentSettingsRepository.findFirstByOrderByIdAsc()
                .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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

findAll()을 사용하여 설정을 조회하는 대신, AiCommentSettingsRepository에 추가를 제안한 findFirstByOrderByIdAsc() 메서드를 사용하는 것이 좋습니다. 이 방법은 의도를 더 명확하게 드러내고, 여러 설정 행이 존재할 경우의 예기치 않은 동작을 방지하며, 더 효율적입니다.

        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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

ai_comment_settings 테이블은 단일 행으로 관리되는 전역 설정을 저장하는 것으로 보입니다. findAll()을 사용하여 설정을 조회하면, 실수로 여러 행이 추가되었을 때 어떤 행을 가져올지 보장할 수 없으며 불필요하게 모든 데이터를 조회하는 비효율이 발생할 수 있습니다. ID를 기준으로 정렬하여 첫 번째 설정만 가져오는 명시적인 메서드를 추가하는 것이 좋습니다.

Suggested change
public interface AiCommentSettingsRepository extends JpaRepository<AiCommentSettings, Long> {
}
public interface AiCommentSettingsRepository extends JpaRepository<AiCommentSettings, Long> {
java.util.Optional<AiCommentSettings> findFirstByOrderByIdAsc();
}

Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

/admin/** 경로에 대해 일괄적으로 ADMIN 권한을 부여하는 것은 너무 광범위하여 의도치 않은 보안 위험을 초래할 수 있습니다. 향후 /admin 경로 하위에 다른 접근 제어가 필요한 API가 추가될 경우, 이 설정 때문에 모두 ADMIN 전용으로 제한될 수 있습니다. 새로 추가된 AI 댓글 관리 API 경로에 대해서만 구체적으로 권한을 설정하는 것이 더 안전하고 명확합니다.

Suggested change
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/admin/ai-comments/**").hasRole("ADMIN")


/**
* 위에서 등록되지 않은 모든 경로는 인증 필요
Expand Down
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);
}
}
}
Loading
Loading