diff --git a/build.gradle b/build.gradle index d03bd9b..37c35f1 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' runtimeOnly "io.netty:netty-resolver-dns-native-macos:4.1.110.Final:osx-aarch_64" diff --git a/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java b/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java index d0c0ca7..176b145 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java +++ b/src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java @@ -24,6 +24,8 @@ public static class QuestionDto { @JsonInclude(Include.NON_NULL) public static class GptQuestion { private String question; + private String topic; + private String keyword; } @Getter diff --git a/src/main/java/com/project/InsightPrep/domain/question/entity/ItemType.java b/src/main/java/com/project/InsightPrep/domain/question/entity/ItemType.java new file mode 100644 index 0000000..bfd148d --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/entity/ItemType.java @@ -0,0 +1,5 @@ +package com.project.InsightPrep.domain.question.entity; + +public enum ItemType { + TOPIC, KEYWORD +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/entity/RecentPromptFilter.java b/src/main/java/com/project/InsightPrep/domain/question/entity/RecentPromptFilter.java new file mode 100644 index 0000000..57fdd7e --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/entity/RecentPromptFilter.java @@ -0,0 +1,64 @@ +package com.project.InsightPrep.domain.question.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "recent_prompt_filters", + indexes = { + @Index(name = "idx_recent_10", columnList = "member_id, category, item_type, created_at") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uq_user_cat_type_value", + columnNames = {"member_id", "category", "item_type", "item_value"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RecentPromptFilter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(nullable = false, length = 50) + private String category; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ItemType itemType; // 주제 / 키워드 + + @Column(nullable = false, length = 200) + private String itemValue; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/mapper/RecentPromptFilterMapper.java b/src/main/java/com/project/InsightPrep/domain/question/mapper/RecentPromptFilterMapper.java new file mode 100644 index 0000000..de9cfb8 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/mapper/RecentPromptFilterMapper.java @@ -0,0 +1,18 @@ +package com.project.InsightPrep.domain.question.mapper; + +import com.project.InsightPrep.domain.question.entity.ItemType; +import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface RecentPromptFilterMapper { + void insert(RecentPromptFilter recentPromptFilter); + + List findTopNByUserCategoryType( + @Param("memberId") long memberId, + @Param("category") String category, + @Param("type")ItemType type, + @Param("limit") int limit); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/RecentPromptFilterService.java b/src/main/java/com/project/InsightPrep/domain/question/service/RecentPromptFilterService.java new file mode 100644 index 0000000..b5a7904 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/service/RecentPromptFilterService.java @@ -0,0 +1,11 @@ +package com.project.InsightPrep.domain.question.service; + +import com.project.InsightPrep.domain.question.entity.ItemType; +import java.util.List; + +public interface RecentPromptFilterService { + + void record(long memberId, String category, ItemType type, String value); + + List getRecent(long memberId, String category, ItemType type, int limit); +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java index 9967890..070241b 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java @@ -6,11 +6,14 @@ import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionDto; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.domain.question.entity.AnswerStatus; +import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.domain.question.service.QuestionService; +import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import com.project.InsightPrep.global.auth.util.SecurityUtil; +import com.project.InsightPrep.global.gpt.dto.response.GptMessage; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; import com.project.InsightPrep.global.gpt.service.GptResponseType; import com.project.InsightPrep.global.gpt.service.GptService; @@ -28,13 +31,26 @@ public class QuestionServiceImpl implements QuestionService { private final GptService gptService; private final QuestionMapper questionMapper; private final AnswerMapper answerMapper; + private final RecentPromptFilterService recentPromptFilterService; private final SecurityUtil securityUtil; @Override @Transactional public QuestionDto createQuestion(String category) { - GptQuestion gptQuestion = gptService.callOpenAI(PromptFactory.forQuestionGeneration(category), 1000, 0.6, GptResponseType.QUESTION); + long memberId = securityUtil.getLoginMemberId(); + // 1) 최근 금지 주제/키워드 조회 (없을 수 있음) + List bannedTopics = recentPromptFilterService.getRecent(memberId, category, ItemType.TOPIC, 10); + List bannedKeywords = recentPromptFilterService.getRecent(memberId, category, ItemType.KEYWORD, 10); + + // 2) 프롬프트 선택 (있으면 주입, 없으면 기본) + List prompt = (hasAny(bannedTopics, bannedKeywords)) + ? PromptFactory.forQuestionGeneration(category, bannedTopics, bannedKeywords) + : PromptFactory.forQuestionGeneration(category); + // 3) 호출 + GptQuestion gptQuestion = gptService.callOpenAI(prompt, 1000, 0.6, GptResponseType.QUESTION); + + // 4) DB에 저장 Question question = Question.builder() .category(category) .content(gptQuestion.getQuestion()) @@ -43,6 +59,14 @@ public QuestionDto createQuestion(String category) { questionMapper.insertQuestion(question); + // 5) 기록 (Redis + DB) - 응답에 topic/keyword가 비어있을 수도 있으므로 방어 + if (isNotBlank(gptQuestion.getTopic())) { + recentPromptFilterService.record(memberId, category, ItemType.TOPIC, gptQuestion.getTopic()); + } + if (isNotBlank(gptQuestion.getKeyword())) { + recentPromptFilterService.record(memberId, category, ItemType.KEYWORD, gptQuestion.getKeyword()); + } + return QuestionResponse.QuestionDto.builder() .id(question.getId()) .content(question.getContent()) @@ -64,4 +88,10 @@ public PageResponse getQuestions(int page, int size) { long total = answerMapper.countQuestionsWithFeedback(memberId); return PageResponse.of(content, safePage, safeSize, total); } + + private boolean hasAny(List a, List b) { + return (a != null && !a.isEmpty()) || (b != null && !b.isEmpty()); + } + + private boolean isNotBlank(String s) { return s != null && !s.isBlank(); } } diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java new file mode 100644 index 0000000..f9b16fd --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImpl.java @@ -0,0 +1,82 @@ +package com.project.InsightPrep.domain.question.service.impl; + +import com.project.InsightPrep.domain.question.entity.ItemType; +import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; +import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; +import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RecentPromptFilterServiceImpl implements RecentPromptFilterService { + + private final StringRedisTemplate redis; + private final RecentPromptFilterMapper recentMapper; + private static final String KEY_FMT = "rp:%d:%s:%s"; // memberId, category, type + public static final int MAX_SIZE = 10; + public static final Duration TTL = Duration.ofDays(14); // 만료일 + + @Override + @Transactional + public void record(long memberId, String category, ItemType type, String value) { + // DB 영구 저장 (unique 제약 조건으로 중복 방지) + RecentPromptFilter recentPromptFilter = RecentPromptFilter.builder() + .memberId(memberId) + .category(category) + .itemType(type) + .itemValue(value) + .build(); + try { + recentMapper.insert(recentPromptFilter); + } catch (DataIntegrityViolationException ignore) { + // 유니크 제약 충돌은 무시 (이미 기록된 값) + } + + // redis 캐시 (최근 10개 유지) + String key = key(memberId, category, type); + double score = System.currentTimeMillis(); + redis.opsForZSet().add(key, value, score); + + // 오래된 것 제거 + Long size = redis.opsForZSet().size(key); // ZSet: 낮은 rank가 오래된 것 + if (size != null && size > MAX_SIZE) { + redis.opsForZSet().removeRange(key, 0, size - MAX_SIZE - 1); + } + + redis.expire(key, TTL); // TTL 적용 + } + + @Override + @Transactional(readOnly = true) + public List getRecent(long memberId, String category, ItemType type, int limit) { + String key = key(memberId, category, type); + + // 최신순 상위 N + Set z = redis.opsForZSet().reverseRange(key, 0, Math.max(0, limit - 1)); + if (z != null && !z.isEmpty()) { + return new ArrayList<>(z); + } + + // 캐시 미스 → DB fallback (최근 10개) + List fromDb = recentMapper.findTopNByUserCategoryType(memberId, category, type, limit); + for (int i = 0; i < fromDb.size(); i++) { + redis.opsForZSet().add(key, fromDb.get(i), System.currentTimeMillis() + i); + } + redis.expire(key, TTL); + return fromDb; + } + + private String key(long userId, String category, ItemType type) { + return String.format(KEY_FMT, userId, category, type.name()); + } +} diff --git a/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java index 4e75ed7..1d82a31 100644 --- a/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/CorsConfig.java @@ -13,7 +13,7 @@ public class CorsConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:8080")); - cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); + cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS", "PATCH")); cfg.setAllowedHeaders(List.of("*")); cfg.setAllowCredentials(true); cfg.setMaxAge(3600L); diff --git a/src/main/java/com/project/InsightPrep/global/config/RedisConfig.java b/src/main/java/com/project/InsightPrep/global/config/RedisConfig.java new file mode 100644 index 0000000..0f34e3e --- /dev/null +++ b/src/main/java/com/project/InsightPrep/global/config/RedisConfig.java @@ -0,0 +1,15 @@ +package com.project.InsightPrep.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java index 973e8af..4a025f8 100644 --- a/src/main/java/com/project/InsightPrep/global/config/WebConfig.java +++ b/src/main/java/com/project/InsightPrep/global/config/WebConfig.java @@ -11,7 +11,7 @@ public class WebConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8080", "http://localhost:5173") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") .allowCredentials(true); // 세션 쿠키 사용할 경우 true } diff --git a/src/main/java/com/project/InsightPrep/global/gpt/prompt/PromptFactory.java b/src/main/java/com/project/InsightPrep/global/gpt/prompt/PromptFactory.java index 327a5b1..f7e41c1 100644 --- a/src/main/java/com/project/InsightPrep/global/gpt/prompt/PromptFactory.java +++ b/src/main/java/com/project/InsightPrep/global/gpt/prompt/PromptFactory.java @@ -1,6 +1,7 @@ package com.project.InsightPrep.global.gpt.prompt; import com.project.InsightPrep.global.gpt.dto.response.GptMessage; +import java.util.Collections; import java.util.List; public class PromptFactory { @@ -9,16 +10,94 @@ private PromptFactory() {} // 질문 생성용 프롬프트 public static List forQuestionGeneration(String category) { + // 기존 호출부 호환: 금지 목록 없이 호출되면 빈 리스트로 대체 + return forQuestionGeneration(category, Collections.emptyList(), Collections.emptyList()); + } + + // 동적 금지 토픽/키워드 주입 버전 + public static List forQuestionGeneration(String category, List bannedTopics, List bannedKeywords) { String systemPrompt = """ - 당신은 예리하고 경험 많은 소프트웨어 개발자 면접관입니다. - 지원자의 수준을 파악할 수 있는 깊이 있는 CS 면접 질문을 생성해야 합니다. + 당신은 예리하고 경험 많은 소프트웨어 개발자 면접관입니다. + 지원자의 수준을 파악할 수 있는 깊이 있는 CS 면접 질문을 생성해야 합니다. 질문은 실무와 밀접하게 연관되며, 개념 이해를 바탕으로 응답자의 사고력을 평가할 수 있어야 합니다. 응답은 질문 하나로만 구성되어야 하며, 질문 외의 설명이나 해설은 포함하지 마세요. - 아래 JSON 형식을 지켜서 응답해 주세요. - { \\"question\\": \\"...\\" } + 아래 JSON 형식을 지켜서 응답해 주세요. 단, JSON만 출력하되, 코드블록은 출력하지 마세요. + { + \\"question\\": \\"...\\", + \\"topic\\": \\"...\\", // 질문을 대표하는 짧은 주제 문구 (예: \\"volatile의 메모리 가시성\\") + \\"keyword\\": \\"...\\" // 중복 방지용 핵심 단어 (예: \\"volatile\\", \\"hashmap\\", \\"dijkstra\\") + } + """; + + String guardrails = switch (category.toLowerCase()) { + case "algorithm" -> """ + [카테고리: 알고리즘(코딩테스트/기술면접용)] + 포함 예시: 시간복잡도/공간복잡도, 정렬, 탐색(Binary Search), 투포인터, 슬라이딩 윈도우, 스택/큐/우선순위큐/해시, 그래프(DFS/BFS), 최단경로(다익스트라/벨만-포드), 최소신장트리(크루스칼/프림), + 위상정렬, 동적계획법(LIS/LCS/Knapsack 등), 비트마스킹, 분할정복, 그리디, 유니온파인드, 세그먼트트리/펜윅트리 등. + 반드시 제외: 머신러닝/딥러닝/통계/확률/최적화(경사하강법, CNN/RNN/Transformer, SVM, KMeans 등) + """; + case "java" -> """ + [카테고리: Java] + 주제를 고르게 분산: 언어 기초(클래스/인터페이스/추상/상속/다형성), 제네릭/애너테이션/레코드, 예외/에러 처리, 컬렉션/동등성(equals/hashCode), 스트림/람다/함수형 인터페이스, + 동시성(스레드/락/volatile/Atomic/CompletableFuture), I/O/NIO, 모듈 시스템, JVM(클래스로더, 메모리 구조, JIT) 등. GC/volatile 등 특정 주제가 반복 출제되지 않도록 분산. + 프레임워크(Spring 등) 종속 질문은 피하고 순수 Java 중심으로. + """; + case "os" -> """ + [카테고리: 운영체제] + 프로세스/스레드/CPU 스케줄링, 동기화(뮤텍스/세마포어/모니터), 교착상태, + 메모리 관리(페이징/세그멘테이션/교체 알고리즘), 파일시스템, 시스템콜 등. + """; + case "network", "computer network" -> """ + [카테고리: 네트워크] + OSI/TCP-IP, TCP/UDP 차이/흐름·혼잡제어, 3-way/4-way, HTTP/1.1 vs 2 vs 3, TLS/HTTPS, + DNS/CDN/캐시, 프록시/로드밸런싱 등. + """; + case "db", "database" -> """ + [카테고리: 데이터베이스] + 정규화/인덱스/트랜잭션/격리수준, 쿼리 최적화, 조인 전략, 샤딩/리플리케이션, NoSQL vs RDB 등. + """; + case "spring" -> """ + [카테고리: Spring] + DI/IoC, AOP, 트랜잭션 관리, MVC 구조, 빈 생명주기, 예외 처리, Validation 등(Java 일반보다 프레임워크 중심). + """; + default -> """ + [카테고리: 일반 CS] + 자료구조/알고리즘/OS/네트워크/DB/소프트웨어 공학 등 면접용 정통 CS 범위에서 출제. + 머신러닝/딥러닝/데이터사이언스 주제는 제외. + """; + }; + + String diversityRules = """ + 추가 지침: + - 최근에 출제된 주제/키워드와 **중복 금지**. (아래 금지 목록을 반드시 피하세요) + - 지나치게 광범위한 '모두 설명하라' 대신, 하나의 개념/기법/상황을 날카롭게 파고드는 질문으로. + - 동일 카테고리 내에서도 하위 주제를 **순환**하며 다양성을 유지하세요. """; - String userPrompt = category + "에 관한 CS(Computer System) 면접 질문 하나를 만들어 주세요."; + String bannedSection = ""; + if (bannedTopics != null && !bannedTopics.isEmpty()) { + bannedSection += "금지 토픽(최근): " + String.join(", ", bannedTopics) + "\n"; + } + if (bannedKeywords != null && !bannedKeywords.isEmpty()) { + bannedSection += "금지 키워드(최근): " + String.join(", ", bannedKeywords) + "\n"; + } + if (!bannedSection.isEmpty()) { + bannedSection = "\n[중복 방지용 금지 목록]\n" + bannedSection; + } + + String userPrompt = """ + 다음 카테고리에 대한 CS 면접 질문 1개를 생성하세요. + 카테고리: %s + + %s + + %s + %s + + 출력 형식(JSON): + { "question": "...", "topic": "...", "keyword": "..." } + """.formatted(category, guardrails, diversityRules, bannedSection); + return toMessages(systemPrompt, userPrompt); } diff --git a/src/main/resources/mapper/recent-prompt-filter-mapper.xml b/src/main/resources/mapper/recent-prompt-filter-mapper.xml new file mode 100644 index 0000000..a0a823b --- /dev/null +++ b/src/main/resources/mapper/recent-prompt-filter-mapper.xml @@ -0,0 +1,26 @@ + + + + + + + + INSERT INTO recent_prompt_filters + (member_id, category, item_type, item_value, created_at) + VALUES + (#{memberId}, #{category}, #{itemType}, #{itemValue}, NOW()) + ON CONFLICT (member_id, category, item_type, item_value) DO NOTHING + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql-map-config.xml b/src/main/resources/sql-map-config.xml index 2d7ec4b..77fe578 100644 --- a/src/main/resources/sql-map-config.xml +++ b/src/main/resources/sql-map-config.xml @@ -9,6 +9,7 @@ + diff --git a/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java b/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java index eac5a00..30b9742 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/controller/CommentControllerTest.java @@ -71,7 +71,7 @@ void createComment_success() throws Exception { .andExpect(jsonPath("$.result.commentId").value(777L)) .andExpect(jsonPath("$.result.content").value("첫 댓글")) .andExpect(jsonPath("$.result.postId").value((int) postId)) - .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.CREATE_COMMENT_SUCCESS.name())))); verify(commentService).createComment(eq(postId), ArgumentMatchers.any(CommentRequest.CreateDto.class)); verifyNoMoreInteractions(commentService); @@ -94,7 +94,7 @@ void updateComment_success() throws Exception { .content(objectMapper.writeValueAsString(req))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").doesNotExist()) - .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.UPDATE_COMMENT_SUCCESS.name())))); verify(commentService).updateComment(eq(postId), eq(commentId), ArgumentMatchers.any(CommentRequest.UpdateDto.class)); verifyNoMoreInteractions(commentService); @@ -113,7 +113,7 @@ void deleteComment_success() throws Exception { .with(csrf())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").doesNotExist()) - .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.SUCCESS.name())))); + .andExpect(jsonPath("$.code", anyOf(nullValue(), is(ApiSuccessCode.DELETE_COMMENT_SUCCESS.name())))); verify(commentService).deleteComment(eq(postId), eq(commentId)); verifyNoMoreInteractions(commentService); @@ -155,7 +155,7 @@ void listComments_success_defaultParams() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) // ApiResponse 공통 래퍼 구조 검증 - .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.code").value("GET_COMMENTS_SUCCESS")) .andExpect(jsonPath("$.message", not(isEmptyOrNullString()))) // 페이징 필드 .andExpect(jsonPath("$.result.page").value(page)) @@ -191,7 +191,7 @@ void listComments_success_customParams() throws Exception { .with(user("u2").roles("USER")) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.code").value("GET_COMMENTS_SUCCESS")) .andExpect(jsonPath("$.result.page").value(page)) .andExpect(jsonPath("$.result.size").value(size)) .andExpect(jsonPath("$.result.totalElements").value(0)) diff --git a/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java b/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java index 38fa054..b46a120 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/controller/PostControllerTest.java @@ -111,13 +111,13 @@ void resolve_success() throws Exception { mockMvc.perform(patch("/post/{postId}/resolve", postId) .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.code").value("UPDATE_POST_STATUS_SUCCESS")) .andExpect(jsonPath("$.result").doesNotExist()); } @Test @WithMockUser(roles = "USER") - @DisplayName("GET /api/posts : 목록 + 페이징 성공") + @DisplayName("GET /post : 목록 + 페이징 성공") void list_success() throws Exception { int page = 2, size = 3; @@ -143,7 +143,7 @@ void list_success() throws Exception { .param("page", String.valueOf(page)) .param("size", String.valueOf(size))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.code").value("GET_POSTS_SUCCESS")) .andExpect(jsonPath("$.result.page").value(page)) .andExpect(jsonPath("$.result.size").value(size)) .andExpect(jsonPath("$.result.totalElements").value(20)) diff --git a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java index a49de9c..b0356b7 100644 --- a/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/post/service/impl/CommentServiceImplTest.java @@ -324,8 +324,6 @@ void delete_forbidden() { when(commentMapper.findRowById(commentId)) .thenReturn(new CommentRow(commentId, postId, owner, "x")); when(securityUtil.getLoginMemberId()).thenReturn(me); - // 구현상 먼저 deleteByIdAndMember를 호출한 뒤 소유자 검사 → 0 반환될 수 있음 - when(commentMapper.deleteByIdAndMember(commentId, me)).thenReturn(0); assertThatThrownBy(() -> commentService.deleteComment(postId, commentId)) .isInstanceOf(PostException.class) @@ -334,7 +332,7 @@ void delete_forbidden() { verify(sharedPostMapper).findById(postId); verify(commentMapper).findRowById(commentId); verify(securityUtil).getLoginMemberId(); - verify(commentMapper).deleteByIdAndMember(commentId, me); + verifyNoMoreInteractions(commentMapper); } } diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java index 65405e9..6cde33a 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImplTest.java @@ -18,9 +18,11 @@ import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.GptQuestion; import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto; import com.project.InsightPrep.domain.question.entity.AnswerStatus; +import com.project.InsightPrep.domain.question.entity.ItemType; import com.project.InsightPrep.domain.question.entity.Question; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; +import com.project.InsightPrep.domain.question.service.RecentPromptFilterService; import com.project.InsightPrep.global.auth.util.SecurityUtil; import com.project.InsightPrep.global.gpt.service.GptService; import java.lang.reflect.Field; @@ -45,6 +47,9 @@ class QuestionServiceImplTest { @Mock private AnswerMapper answerMapper; + @Mock + private RecentPromptFilterService recentPromptFilterService; + @Mock private SecurityUtil securityUtil; @@ -56,23 +61,32 @@ class QuestionServiceImplTest { void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { // given String category = "OS"; + long memberId = 7L; + + // 로그인 사용자 id 필요 + when(securityUtil.getLoginMemberId()).thenReturn(memberId); + + // 최근 금지 토픽/키워드 조회는 빈 리스트로 + when(recentPromptFilterService.getRecent(eq(memberId), eq(category), eq(ItemType.TOPIC), anyInt())) + .thenReturn(List.of()); + when(recentPromptFilterService.getRecent(eq(memberId), eq(category), eq(ItemType.KEYWORD), anyInt())) + .thenReturn(List.of()); - // GPT 응답 Mock 설정 + // GPT 응답 Mock (topic/keyword도 채움: record 시 NPE 방지) GptQuestion mockGptQuestion = GptQuestion.builder() - .question("운영체제에서 프로세스와 스레드의 차이를 설명하세요.").build(); + .question("운영체제에서 프로세스와 스레드의 차이를 설명하세요.") + .topic("프로세스 vs 스레드") + .keyword("thread") + .build(); when(gptService.callOpenAI(any(), anyInt(), anyDouble(), any())) .thenReturn(mockGptQuestion); - // Question 객체가 저장될 때, id가 설정되어 있다고 가정 - // MyBatis insert 후에 객체에 id가 설정되는 구조이므로, 직접 설정 필요 - // Entity에 @Setter를 두는 것을 선호하지 않기 때문에 리플렉션을 통해 id 필드에 강제로 값을 주입 (테스트 코드에서만 사용하므로 이 방식 적용) + // insert 시 id 주입 doAnswer(invocation -> { Question q = invocation.getArgument(0); - Field idField = Question.class.getDeclaredField("id"); idField.setAccessible(true); - idField.set(q, 123L); // id 직접 설정 - + idField.set(q, 123L); return null; }).when(questionMapper).insertQuestion(any(Question.class)); @@ -86,8 +100,17 @@ void createQuestion_ShouldGenerateQuestionAndInsertIntoDatabase() { assertEquals("운영체제에서 프로세스와 스레드의 차이를 설명하세요.", result.getContent()); assertEquals(AnswerStatus.WAITING, result.getStatus()); - // insert가 실제로 호출되었는지 확인 - verify(questionMapper, times(1)).insertQuestion(any(Question.class)); + // 핵심 상호작용 검증 + InOrder inOrder = inOrder(securityUtil, recentPromptFilterService, gptService, questionMapper); + inOrder.verify(securityUtil).getLoginMemberId(); + inOrder.verify(recentPromptFilterService).getRecent(memberId, category, ItemType.TOPIC, 10); + inOrder.verify(recentPromptFilterService).getRecent(memberId, category, ItemType.KEYWORD, 10); + inOrder.verify(gptService).callOpenAI(any(), anyInt(), anyDouble(), any()); + inOrder.verify(questionMapper).insertQuestion(any(Question.class)); + inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.TOPIC, "프로세스 vs 스레드"); + inOrder.verify(recentPromptFilterService).record(memberId, category, ItemType.KEYWORD, "thread"); + + verifyNoMoreInteractions(recentPromptFilterService, gptService, questionMapper, securityUtil); } @Test diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java new file mode 100644 index 0000000..ada9376 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/RecentPromptFilterServiceImplTest.java @@ -0,0 +1,141 @@ +package com.project.InsightPrep.domain.question.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.project.InsightPrep.domain.question.entity.ItemType; +import com.project.InsightPrep.domain.question.entity.RecentPromptFilter; +import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper; +import java.time.Duration; +import java.util.*; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +@ExtendWith(MockitoExtension.class) +class RecentPromptFilterServiceImplTest { + + @Mock StringRedisTemplate redis; + @Mock RecentPromptFilterMapper recentMapper; + @Mock ZSetOperations zset; + + @InjectMocks RecentPromptFilterServiceImpl service; + + private final long memberId = 42L; + private final String category = "Java"; + private final ItemType type = ItemType.TOPIC; + private final String value = "volatile"; + + private String key; + + @BeforeEach + void setUp() { + when(redis.opsForZSet()).thenReturn(zset); + key = String.format("rp:%d:%s:%s", memberId, category, type.name()); + } + + @Nested + class RecordTests { + + @Test + @DisplayName("record() - 정상: DB insert + Redis ZSET add + TTL + 오래된 항목 trim") + void record_ok() { + // given: DB insert 정상 + doNothing().when(recentMapper).insert(any(RecentPromptFilter.class)); + // size가 15라고 가정 → MAX_SIZE(10) 초과 → 0..4 삭제 호출 + when(zset.size(key)).thenReturn(15L); + + // when + service.record(memberId, category, type, value); + + // then: DB insert 호출 + ArgumentCaptor cap = ArgumentCaptor.forClass(RecentPromptFilter.class); + verify(recentMapper).insert(cap.capture()); + RecentPromptFilter saved = cap.getValue(); + assertThat(saved.getMemberId()).isEqualTo(memberId); + assertThat(saved.getCategory()).isEqualTo(category); + assertThat(saved.getItemType()).isEqualTo(type); + assertThat(saved.getItemValue()).isEqualTo(value); + + // Redis: ZADD + size + removeRange + expire + verify(zset).add(eq(key), eq(value), anyDouble()); + verify(zset).size(key); + // size=15, MAX=10 → 0..(15-10-1)=0..4 제거 + verify(zset).removeRange(key, 0, 4); + verify(redis).expire(eq(key), eq(RecentPromptFilterServiceImpl.TTL)); + } + + @Test + @DisplayName("record() - DB 유니크 충돌 시 예외 무시하고 Redis는 정상 갱신") + void record_duplicate_ignoreDbError() { + // given: unique 제약 충돌 유발 + doThrow(new DataIntegrityViolationException("dup")).when(recentMapper).insert(any(RecentPromptFilter.class)); + when(zset.size(key)).thenReturn(1L); // trim 안 일어나도록 + + // when/then + assertDoesNotThrow(() -> service.record(memberId, category, type, value)); + + // DB insert 시도는 했지만, 예외는 흡수 + verify(recentMapper).insert(any(RecentPromptFilter.class)); + + // Redis는 정상 갱신 + verify(zset).add(eq(key), eq(value), anyDouble()); + verify(redis).expire(eq(key), eq(RecentPromptFilterServiceImpl.TTL)); + // size가 MAX 이하 → trim 안 함 + verify(zset, never()).removeRange(anyString(), anyLong(), anyLong()); + } + } + + @Nested + class GetRecentTests { + + @Test + @DisplayName("getRecent() - 캐시 HIT: Redis ZSET에서 최신 N개 반환, DAO 호출 안 함") + void getRecent_cacheHit() { + // given + Set cached = new LinkedHashSet<>(List.of("a", "b", "c")); + when(zset.reverseRange(key, 0, 4)).thenReturn(cached); + + // when + List res = service.getRecent(memberId, category, type, 5); + + // then + assertThat(res).containsExactly("a", "b", "c"); + verify(recentMapper, never()).findTopNByUserCategoryType(anyLong(), anyString(), any(), anyInt()); + verify(redis, never()).expire(anyString(), any(Duration.class)); // 캐시 HIT 시 expire 갱신 안 함(구현 그대로 검증) + } + + @Test + @DisplayName("getRecent() - 캐시 MISS: DB fallback 후 Redis warm-up 및 반환") + void getRecent_cacheMiss_dbFallbackAndWarmup() { + // given: 캐시 미스 + when(zset.reverseRange(key, 0, 9)).thenReturn(Collections.emptySet()); + + List fromDb = List.of("t1", "t2", "t3"); + when(recentMapper.findTopNByUserCategoryType(memberId, category, type, 10)) + .thenReturn(fromDb); + + // when + List res = service.getRecent(memberId, category, type, 10); + + // then + assertThat(res).containsExactlyElementsOf(fromDb); + + // DB 호출 + verify(recentMapper).findTopNByUserCategoryType(memberId, category, type, 10); + + // Redis warm-up: add 3번 + expire + verify(zset, times(fromDb.size())).add(eq(key), anyString(), anyDouble()); + verify(redis).expire(eq(key), eq(RecentPromptFilterServiceImpl.TTL)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/project/InsightPrep/global/gpt/prompt/PromptFactoryTest.java b/src/test/java/com/project/InsightPrep/global/gpt/prompt/PromptFactoryTest.java index 0e29a90..3bdb34a 100644 --- a/src/test/java/com/project/InsightPrep/global/gpt/prompt/PromptFactoryTest.java +++ b/src/test/java/com/project/InsightPrep/global/gpt/prompt/PromptFactoryTest.java @@ -4,15 +4,19 @@ import static org.junit.jupiter.api.Assertions.*; import com.project.InsightPrep.global.gpt.dto.response.GptMessage; +import java.util.Collections; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; class PromptFactoryTest { @Test - @DisplayName("질문 생성 프롬프트 테스트") - void testForQuestionGeneration_shouldReturnValidMessages() { + @DisplayName("질문 생성 프롬프트 - 기본(금지목록 없음)") + void forQuestionGeneration_basic_noBans() { // given String category = "운영체제"; @@ -22,15 +26,132 @@ void testForQuestionGeneration_shouldReturnValidMessages() { // then assertThat(messages).hasSize(2); - GptMessage systemMessage = messages.get(0); - GptMessage userMessage = messages.get(1); + GptMessage system = messages.get(0); + GptMessage user = messages.get(1); - assertThat(systemMessage.getRole()).isEqualTo("system"); - assertThat(systemMessage.getContent()).contains("당신은 예리하고 경험 많은 소프트웨어 개발자 면접관입니다"); + assertThat(system.getRole()).isEqualTo("system"); + assertThat(system.getContent()) + .contains("예리하고 경험 많은 소프트웨어 개발자 면접관") + .contains("JSON 형식을 지켜서 응답") + .contains("JSON만 출력") + .doesNotContain("```"); // 코드블록 금지 지시 포함 확인 - assertThat(userMessage.getRole()).isEqualTo("user"); - assertThat(userMessage.getContent()).contains(category); - assertThat(userMessage.getContent()).contains("면접 질문"); + assertThat(user.getRole()).isEqualTo("user"); + assertThat(user.getContent()) + .contains("카테고리: " + category) + .contains("출력 형식(JSON)") + .doesNotContain("[중복 방지용 금지 목록]"); // 금지 목록 섹션 없음 } + @Test + @DisplayName("질문 생성 프롬프트 - 금지 토픽/키워드 주입") + void forQuestionGeneration_withBans() { + // given + String category = "Java"; + List bannedTopics = List.of("volatile의 메모리 가시성", "GC 튜닝"); + List bannedKeywords = List.of("volatile", "hashmap"); + + // when + List messages = PromptFactory.forQuestionGeneration(category, bannedTopics, bannedKeywords); + + // then + GptMessage user = messages.get(1); + String content = user.getContent(); + + assertThat(content).contains("[중복 방지용 금지 목록]"); + assertThat(content).contains("금지 토픽(최근): volatile의 메모리 가시성, GC 튜닝"); + assertThat(content).contains("금지 키워드(최근): volatile, hashmap"); + // Java 가드레일 존재 + assertThat(content).contains("[카테고리: Java]"); + // 특정 주제 반복 방지 지침 존재 + assertThat(content).contains("동일 카테고리 내에서도 하위 주제를 **순환**"); + } + + @Test + @DisplayName("질문 생성 프롬프트 - 빈 금지목록 주입 시 기본 오버로드와 동일") + void forQuestionGeneration_emptyBans_equalsNoBans() { + // given + String category = "OS"; + + // when + List a = PromptFactory.forQuestionGeneration(category); + List b = PromptFactory.forQuestionGeneration(category, Collections.emptyList(), Collections.emptyList()); + + // then + assertThat(a.get(1).getContent()).isEqualTo(b.get(1).getContent()); + assertThat(a.get(0).getContent()).isEqualTo(b.get(0).getContent()); + } + + @ParameterizedTest(name = "질문 생성 프롬프트 - 카테고리 가드레일 삽입: {0}") + @MethodSource("categoryGuardrails") + void forQuestionGeneration_guardrails(GuardrailCase c) { + // when + List messages = PromptFactory.forQuestionGeneration(c.input()); + + // then + GptMessage user = messages.get(1); + assertThat(user.getContent()).contains(c.expectedMarker()); + assertThat(user.getContent()).contains("출력 형식(JSON):"); + assertThat(user.getContent()).contains("{ \"question\": \"...\", \"topic\": \"...\", \"keyword\": \"...\" }"); + } + + static Stream categoryGuardrails() { + return Stream.of( + new GuardrailCase("algorithm", "[카테고리: 알고리즘(코딩테스트/기술면접용)]"), + new GuardrailCase("java", "[카테고리: Java]"), + new GuardrailCase("OS", "[카테고리: 운영체제]"), + new GuardrailCase("network", "[카테고리: 네트워크]"), + new GuardrailCase("computer network", "[카테고리: 네트워크]"), + new GuardrailCase("db", "[카테고리: 데이터베이스]"), + new GuardrailCase("database", "[카테고리: 데이터베이스]"), + new GuardrailCase("spring", "[카테고리: Spring]"), + new GuardrailCase("unknown-category", "[카테고리: 일반 CS]") + ); + } + + @Test + @DisplayName("시스템 프롬프트 - 질문/토픽/키워드 JSON 스키마 안내 포함") + void systemPrompt_hasSchemaHints_forQuestion() { + List messages = PromptFactory.forQuestionGeneration("java"); + String system = messages.get(0).getContent(); + + assertThat(system) + .contains("JSON 형식을 지켜서 응답해 주세요") + .contains("\\\"question\\\"") + .contains("\\\"topic\\\"") + .contains("\\\"keyword\\\"") + .contains("JSON만 출력") + .doesNotContain("```"); // 코드블록 금지 + } + + @Test + @DisplayName("피드백 프롬프트 - system/user 메시지 형식 및 JSON 스키마 안내") + void forFeedbackGeneration_basic() { + // given + String q = "HashMap과 ConcurrentHashMap의 차이는?"; + String a = "세그먼트 락으로 동시성 보장..."; + + // when + List messages = PromptFactory.forFeedbackGeneration(q, a); + + // then + assertThat(messages).hasSize(2); + GptMessage system = messages.get(0); + GptMessage user = messages.get(1); + + assertThat(system.getRole()).isEqualTo("system"); + assertThat(system.getContent()) + .contains("전문적이고 경험 많은 소프트웨어 개발 면접관") + .contains("\"score\"") + .contains("\"improvement\"") + .contains("\"modelAnswer\"") + .contains("JSON 형식만") + .doesNotContain("```"); + + assertThat(user.getRole()).isEqualTo("user"); + assertThat(user.getContent()) + .contains("질문: " + q) + .contains("사용자 답변: " + a); + } + private record GuardrailCase(String input, String expectedMarker) {} } \ No newline at end of file