Skip to content

Commit 8532711

Browse files
authored
Merge pull request #122 from classic-daramg/feat/ai-comment-admin-controls
[feat] AI ๋Œ“๊ธ€ ์ˆ˜๋™ ํ• ๋‹น ๋ฐ ์ž๋™ ๊ฐ์ง€ ํ† ๊ธ€ API ์ถ”๊ฐ€
2 parents 0f74a90 + ece9448 commit 8532711

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)