Skip to content

Commit ece9448

Browse files
committed
[feat] AI 댓글 수동 할당 및 자동 감지 토글 API 추가
1 parent 0f74a90 commit ece9448

File tree

9 files changed

+428
-1
lines changed

9 files changed

+428
-1
lines changed

src/main/java/com/daramg/server/aicomment/application/AiCommentService.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@
33
import com.daramg.server.aicomment.domain.AiCommentJob;
44
import com.daramg.server.aicomment.domain.AiCommentJobStatus;
55
import com.daramg.server.aicomment.domain.AiCommentJobTriggerType;
6+
import com.daramg.server.aicomment.domain.AiCommentSettings;
67
import com.daramg.server.aicomment.infrastructure.GeminiClient;
78
import com.daramg.server.aicomment.repository.AiCommentJobRepository;
9+
import com.daramg.server.aicomment.repository.AiCommentSettingsRepository;
810
import com.daramg.server.comment.domain.Comment;
911
import com.daramg.server.comment.repository.CommentRepository;
1012
import com.daramg.server.composer.domain.Composer;
1113
import com.daramg.server.composer.domain.ComposerPersona;
1214
import com.daramg.server.composer.repository.ComposerPersonaRepository;
15+
import com.daramg.server.composer.repository.ComposerRepository;
16+
import com.daramg.server.common.exception.NotFoundException;
1317
import com.daramg.server.post.domain.Post;
1418
import com.daramg.server.post.domain.PostStatus;
1519
import com.daramg.server.post.domain.StoryPost;
1620
import com.daramg.server.post.domain.CurationPost;
21+
import com.daramg.server.post.repository.PostRepository;
1722
import com.daramg.server.user.domain.User;
1823
import com.daramg.server.user.repository.UserRepository;
1924
import jakarta.annotation.PostConstruct;
@@ -43,8 +48,11 @@ public class AiCommentService {
4348
private int replyDelayMinutes;
4449

4550
private final AiCommentJobRepository aiCommentJobRepository;
51+
private final AiCommentSettingsRepository aiCommentSettingsRepository;
4652
private final ComposerPersonaRepository composerPersonaRepository;
53+
private final ComposerRepository composerRepository;
4754
private final CommentRepository commentRepository;
55+
private final PostRepository postRepository;
4856
private final UserRepository userRepository;
4957
private final GeminiClient geminiClient;
5058

@@ -66,12 +74,52 @@ private User getBotUser() {
6674
return botUser;
6775
}
6876

77+
@Transactional(readOnly = true)
78+
public boolean isAutoDetectEnabled() {
79+
return aiCommentSettingsRepository.findAll().stream()
80+
.findFirst()
81+
.map(AiCommentSettings::isAutoDetectEnabled)
82+
.orElse(true);
83+
}
84+
85+
@Transactional
86+
public void setAutoDetectEnabled(boolean enabled) {
87+
AiCommentSettings settings = aiCommentSettingsRepository.findAll().stream()
88+
.findFirst()
89+
.orElseThrow(() -> new IllegalStateException("AI 댓글 설정을 찾을 수 없습니다."));
90+
settings.setAutoDetectEnabled(enabled);
91+
log.info("AI 자동 감지 설정 변경 - enabled={}", enabled);
92+
}
93+
94+
@Transactional
95+
public void scheduleManually(Long postId, Long composerId) {
96+
Post post = postRepository.findById(postId)
97+
.orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다."));
98+
Composer composer = composerRepository.findById(composerId)
99+
.orElseThrow(() -> new NotFoundException("작곡가를 찾을 수 없습니다."));
100+
101+
ComposerPersona persona = composerPersonaRepository.findByComposerId(composerId)
102+
.orElseThrow(() -> new NotFoundException("해당 작곡가의 페르소나를 찾을 수 없습니다."));
103+
if (!persona.isActive()) {
104+
throw new IllegalStateException("비활성화된 페르소나입니다.");
105+
}
106+
107+
AiCommentJob job = AiCommentJob.of(post, composer, AiCommentJobTriggerType.ADMIN_ASSIGNED, null, Instant.now());
108+
aiCommentJobRepository.save(job);
109+
log.info("AI 댓글 수동 할당 - postId={}, composerId={}", postId, composerId);
110+
}
111+
69112
@Transactional
70113
public void scheduleForPost(Post post) {
71114
if (post.getPostStatus() != PostStatus.PUBLISHED) {
72115
return;
73116
}
74117

118+
if (!isAutoDetectEnabled()) {
119+
log.info("AI 자동 감지 비활성화 상태 - postId={} 스킵", post.getId());
120+
return;
121+
}
122+
75123
List<Composer> composers = getComposersForPost(post);
76124
log.info("AI 댓글 대상 작곡가 탐지 - postId={}, composers={}", post.getId(),
77125
composers.stream().map(c -> c.getId() + "(" + c.getKoreanName() + ")").toList());

src/main/java/com/daramg/server/aicomment/domain/AiCommentJobTriggerType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
public enum AiCommentJobTriggerType {
44
POST_CREATED,
5-
USER_REPLY
5+
USER_REPLY,
6+
ADMIN_ASSIGNED
67
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.daramg.server.aicomment.domain;
2+
3+
import com.daramg.server.common.domain.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.AccessLevel;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
@Entity
10+
@Getter
11+
@Table(name = "ai_comment_settings")
12+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13+
public class AiCommentSettings extends BaseEntity<AiCommentSettings> {
14+
15+
@Column(name = "auto_detect_enabled", nullable = false)
16+
private boolean autoDetectEnabled = true;
17+
18+
public static AiCommentSettings defaultSettings() {
19+
return new AiCommentSettings();
20+
}
21+
22+
public void setAutoDetectEnabled(boolean enabled) {
23+
this.autoDetectEnabled = enabled;
24+
}
25+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.daramg.server.aicomment.presentation;
2+
3+
import com.daramg.server.aicomment.application.AiCommentService;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.web.bind.annotation.*;
7+
8+
@RestController
9+
@RequestMapping("/admin/ai-comments")
10+
@RequiredArgsConstructor
11+
public class AiCommentAdminController {
12+
13+
private final AiCommentService aiCommentService;
14+
15+
@PostMapping("/posts/{postId}/assign")
16+
@ResponseStatus(HttpStatus.CREATED)
17+
public void assignComposer(
18+
@PathVariable Long postId,
19+
@RequestParam Long composerId
20+
) {
21+
aiCommentService.scheduleManually(postId, composerId);
22+
}
23+
24+
@GetMapping("/settings")
25+
public AutoDetectSettingsResponse getSettings() {
26+
return new AutoDetectSettingsResponse(aiCommentService.isAutoDetectEnabled());
27+
}
28+
29+
@PutMapping("/settings/auto-detect")
30+
@ResponseStatus(HttpStatus.NO_CONTENT)
31+
public void setAutoDetect(@RequestParam boolean enabled) {
32+
aiCommentService.setAutoDetectEnabled(enabled);
33+
}
34+
35+
public record AutoDetectSettingsResponse(boolean autoDetectEnabled) {}
36+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.daramg.server.aicomment.repository;
2+
3+
import com.daramg.server.aicomment.domain.AiCommentSettings;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface AiCommentSettingsRepository extends JpaRepository<AiCommentSettings, Long> {
7+
}

src/main/java/com/daramg/server/auth/config/SecurityConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
7777
.requestMatchers(HttpMethod.POST, "/banners/images").hasRole("ADMIN")
7878
.requestMatchers(HttpMethod.PUT, "/banners/**").hasRole("ADMIN")
7979
.requestMatchers(HttpMethod.DELETE, "/banners/**").hasRole("ADMIN")
80+
.requestMatchers("/admin/**").hasRole("ADMIN")
8081

8182
/**
8283
* 위에서 등록되지 않은 모든 경로는 인증 필요
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TABLE ai_comment_settings (
2+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
3+
auto_detect_enabled BOOLEAN NOT NULL DEFAULT TRUE,
4+
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
5+
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
6+
);
7+
8+
INSERT INTO ai_comment_settings (auto_detect_enabled) VALUES (TRUE);
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package com.daramg.server.aicomment.application;
2+
3+
import com.daramg.server.aicomment.domain.AiCommentJob;
4+
import com.daramg.server.aicomment.domain.AiCommentJobTriggerType;
5+
import com.daramg.server.aicomment.domain.AiCommentSettings;
6+
import com.daramg.server.aicomment.infrastructure.GeminiClient;
7+
import com.daramg.server.aicomment.repository.AiCommentJobRepository;
8+
import com.daramg.server.aicomment.repository.AiCommentSettingsRepository;
9+
import com.daramg.server.common.exception.NotFoundException;
10+
import com.daramg.server.composer.domain.Composer;
11+
import com.daramg.server.composer.domain.ComposerPersona;
12+
import com.daramg.server.composer.domain.Gender;
13+
import com.daramg.server.composer.repository.ComposerPersonaRepository;
14+
import com.daramg.server.composer.repository.ComposerRepository;
15+
import com.daramg.server.post.domain.FreePost;
16+
import com.daramg.server.post.domain.Post;
17+
import com.daramg.server.post.domain.PostStatus;
18+
import com.daramg.server.post.domain.vo.PostCreateVo;
19+
import com.daramg.server.post.repository.PostRepository;
20+
import com.daramg.server.testsupport.support.ServiceTestSupport;
21+
import com.daramg.server.user.domain.User;
22+
import com.daramg.server.user.repository.UserRepository;
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.DisplayName;
25+
import org.junit.jupiter.api.Nested;
26+
import org.junit.jupiter.api.Test;
27+
import org.springframework.beans.factory.annotation.Autowired;
28+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
29+
import org.springframework.test.util.ReflectionTestUtils;
30+
31+
import java.time.LocalDate;
32+
import java.util.List;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
36+
37+
public class AiCommentAdminServiceTest extends ServiceTestSupport {
38+
39+
@Autowired
40+
private AiCommentService aiCommentService;
41+
42+
@Autowired
43+
private AiCommentJobRepository aiCommentJobRepository;
44+
45+
@Autowired
46+
private AiCommentSettingsRepository aiCommentSettingsRepository;
47+
48+
49+
@Autowired
50+
private ComposerRepository composerRepository;
51+
52+
@Autowired
53+
private ComposerPersonaRepository composerPersonaRepository;
54+
55+
@Autowired
56+
private PostRepository postRepository;
57+
58+
@Autowired
59+
private UserRepository userRepository;
60+
61+
@MockitoBean
62+
private GeminiClient geminiClient;
63+
64+
private User user;
65+
private User botUser;
66+
private Composer composer;
67+
private Post post;
68+
69+
@BeforeEach
70+
void setUp() {
71+
user = new User("user@test.com", "password", "테스터", LocalDate.now(), null, "테스터닉", null, null);
72+
userRepository.save(user);
73+
74+
botUser = new User("ai-bot@classicaldaramz.com", "LOCKED", "AI", LocalDate.of(2000, 1, 1), null, "ai_bot", null, null);
75+
userRepository.save(botUser);
76+
ReflectionTestUtils.setField(aiCommentService, "botUser", botUser);
77+
78+
composer = Composer.builder()
79+
.koreanName("베토벤").englishName("Beethoven").gender(Gender.MALE).build();
80+
composerRepository.save(composer);
81+
82+
post = postRepository.save(FreePost.from(new PostCreateVo.Free(
83+
user, "제목", "내용", PostStatus.PUBLISHED, List.of(), null, List.of()
84+
)));
85+
86+
aiCommentSettingsRepository.save(AiCommentSettings.defaultSettings());
87+
}
88+
89+
private ComposerPersona savePersona(Composer c) {
90+
ComposerPersona persona = ComposerPersona.builder()
91+
.composer(c).identity("완벽주의자").mission("연습 독려").constraintsText("반말, 150자 이내").build();
92+
return composerPersonaRepository.save(persona);
93+
}
94+
95+
private AiCommentSettings getSettings() {
96+
return aiCommentSettingsRepository.findAll().get(0);
97+
}
98+
99+
@Nested
100+
@DisplayName("자동 감지 토글")
101+
class AutoDetectToggleTest {
102+
103+
@Test
104+
void 자동감지를_비활성화하면_false가_반환된다() {
105+
// when
106+
aiCommentService.setAutoDetectEnabled(false);
107+
108+
// then
109+
assertThat(aiCommentService.isAutoDetectEnabled()).isFalse();
110+
}
111+
112+
@Test
113+
void 자동감지를_다시_활성화하면_true가_반환된다() {
114+
// given
115+
aiCommentService.setAutoDetectEnabled(false);
116+
117+
// when
118+
aiCommentService.setAutoDetectEnabled(true);
119+
120+
// then
121+
assertThat(aiCommentService.isAutoDetectEnabled()).isTrue();
122+
}
123+
124+
@Test
125+
void 자동감지_비활성화_시_게시물_발행해도_잡이_등록되지_않는다() {
126+
// given
127+
savePersona(composer);
128+
Post mentionPost = postRepository.save(FreePost.from(new PostCreateVo.Free(
129+
user, "베토벤 이야기", "베토벤 내용", PostStatus.PUBLISHED, List.of(), null, List.of()
130+
)));
131+
aiCommentService.setAutoDetectEnabled(false);
132+
133+
// when
134+
aiCommentService.scheduleForPost(mentionPost);
135+
136+
// then
137+
assertThat(aiCommentJobRepository.findAll()).isEmpty();
138+
}
139+
}
140+
141+
@Nested
142+
@DisplayName("수동 할당")
143+
class ManualAssignTest {
144+
145+
@Test
146+
void 수동_할당_시_ADMIN_ASSIGNED_잡이_즉시_등록된다() {
147+
// given
148+
savePersona(composer);
149+
150+
// when
151+
aiCommentService.scheduleManually(post.getId(), composer.getId());
152+
153+
// then
154+
List<AiCommentJob> jobs = aiCommentJobRepository.findAll();
155+
assertThat(jobs).hasSize(1);
156+
assertThat(jobs.get(0).getTriggerType()).isEqualTo(AiCommentJobTriggerType.ADMIN_ASSIGNED);
157+
assertThat(jobs.get(0).getComposer().getId()).isEqualTo(composer.getId());
158+
}
159+
160+
@Test
161+
void 자동감지_비활성화_상태에서도_수동_할당은_가능하다() {
162+
// given
163+
savePersona(composer);
164+
aiCommentService.setAutoDetectEnabled(false);
165+
166+
// when
167+
aiCommentService.scheduleManually(post.getId(), composer.getId());
168+
169+
// then
170+
assertThat(aiCommentJobRepository.findAll()).hasSize(1);
171+
}
172+
173+
@Test
174+
void 존재하지_않는_게시물에_수동_할당_시_예외가_발생한다() {
175+
// when & then
176+
assertThatThrownBy(() -> aiCommentService.scheduleManually(999L, composer.getId()))
177+
.isInstanceOf(NotFoundException.class);
178+
}
179+
180+
@Test
181+
void 페르소나_없는_작곡가에_수동_할당_시_예외가_발생한다() {
182+
// when & then
183+
assertThatThrownBy(() -> aiCommentService.scheduleManually(post.getId(), composer.getId()))
184+
.isInstanceOf(NotFoundException.class);
185+
}
186+
}
187+
}

0 commit comments

Comments
 (0)